Public Access
Compare commits
245 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 082c01fc46 | |||
| f3f356ae54 | |||
| 85d863be08 | |||
| a83eda5798 | |||
| 631b44e1a3 | |||
| 79631d77bb | |||
| 4d06e3828e | |||
| e135a0ff8b | |||
| 86db53d2ac | |||
| 8a4e1ab60f | |||
| 505013c6f1 | |||
| 2f6845c5c0 | |||
| 45233fb9d2 | |||
| ecf6615383 | |||
| 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 |
@@ -42,7 +42,7 @@ Suggested text here
|
|||||||
<!-- Add any other context, screenshots, or references -->
|
<!-- Add any other context, screenshots, or references -->
|
||||||
|
|
||||||
## Standards Alignment
|
## Standards Alignment
|
||||||
- [ ] Follows MokoStandards documentation guidelines
|
- [ ] Follows moko-platform documentation guidelines
|
||||||
- [ ] Uses en_US/en_GB localization
|
- [ ] Uses en_US/en_GB localization
|
||||||
- [ ] Includes proper SPDX headers where applicable
|
- [ ] 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.
|
Add any other context, mockups, or screenshots about the feature request here.
|
||||||
|
|
||||||
## Relevant Standards
|
## 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)
|
- [ ] Accessibility (WCAG 2.1 AA)
|
||||||
- [ ] Localization (en_US/en_GB)
|
- [ ] Localization (en_US/en_GB)
|
||||||
- [ ] Security best practices
|
- [ ] Security best practices
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Use this template only for:
|
|||||||
<!-- Describe how this could be addressed -->
|
<!-- Describe how this could be addressed -->
|
||||||
|
|
||||||
## Standards Reference
|
## 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
|
- [ ] SPDX license identifiers
|
||||||
- [ ] Secret management
|
- [ ] Secret management
|
||||||
- [ ] Dependency security
|
- [ ] Dependency security
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
# | BRANCH PROTECTION SETUP |
|
# | 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 |
|
# | main — Require PR, block rejected reviews, no force push |
|
||||||
# | dev — Allow push, no force push, no delete |
|
# | dev — Allow push, no force push, no delete |
|
||||||
# | rc/* — Allow push, no force push, no delete |
|
# | rc — Allow push, no force push, no delete |
|
||||||
# | beta/* — Allow push, no force push, no delete |
|
# | beta — Allow push, no force push, no delete |
|
||||||
# | alpha/* — Allow push, no force push, no delete |
|
# | alpha — Allow push, no force push, no delete |
|
||||||
# | |
|
# | |
|
||||||
# | jmiller has override authority on all branches. |
|
# | jmiller has override authority on all branches. |
|
||||||
# | |
|
# | |
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
API="${GITEA_URL}/api/v1"
|
API="${GITEA_URL}/api/v1"
|
||||||
|
|
||||||
# Platform/standards/infra repos to exclude
|
# 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"
|
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||||
|
|
||||||
if [ -n "${{ inputs.repos }}" ]; then
|
if [ -n "${{ inputs.repos }}" ]; then
|
||||||
@@ -116,17 +116,18 @@ jobs:
|
|||||||
SKIPPED=0
|
SKIPPED=0
|
||||||
|
|
||||||
# ── Rule definitions ──────────────────────────────────────
|
# ── Rule definitions ──────────────────────────────────────
|
||||||
# Each rule: NAME|JSON_BODY
|
# Only the CI bot (jmiller token) can push directly.
|
||||||
# jmiller has override (force push + push whitelist) on all branches
|
# All human contributors must use PRs.
|
||||||
|
# Force push disabled on all branches.
|
||||||
|
|
||||||
RULE_MAIN='{
|
RULE_MAIN='{
|
||||||
"rule_name": "main",
|
"rule_name": "main",
|
||||||
"enable_push": true,
|
"enable_push": true,
|
||||||
"enable_push_whitelist": true,
|
"enable_push_whitelist": true,
|
||||||
"push_whitelist_usernames": ["jmiller"],
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
"enable_force_push": true,
|
"enable_force_push": false,
|
||||||
"enable_force_push_allowlist": true,
|
"enable_force_push_allowlist": false,
|
||||||
"force_push_allowlist_usernames": ["jmiller"],
|
"force_push_allowlist_usernames": [],
|
||||||
"enable_merge_whitelist": false,
|
"enable_merge_whitelist": false,
|
||||||
"required_approvals": 0,
|
"required_approvals": 0,
|
||||||
"dismiss_stale_approvals": true,
|
"dismiss_stale_approvals": true,
|
||||||
@@ -138,10 +139,11 @@ jobs:
|
|||||||
RULE_DEV='{
|
RULE_DEV='{
|
||||||
"rule_name": "dev",
|
"rule_name": "dev",
|
||||||
"enable_push": true,
|
"enable_push": true,
|
||||||
"enable_push_whitelist": false,
|
"enable_push_whitelist": true,
|
||||||
"enable_force_push": true,
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
"enable_force_push_allowlist": true,
|
"enable_force_push": false,
|
||||||
"force_push_allowlist_usernames": ["jmiller"],
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
"enable_merge_whitelist": false,
|
"enable_merge_whitelist": false,
|
||||||
"required_approvals": 0,
|
"required_approvals": 0,
|
||||||
"block_on_rejected_reviews": false,
|
"block_on_rejected_reviews": false,
|
||||||
@@ -149,12 +151,13 @@ jobs:
|
|||||||
}'
|
}'
|
||||||
|
|
||||||
RULE_RC='{
|
RULE_RC='{
|
||||||
"rule_name": "rc/*",
|
"rule_name": "rc",
|
||||||
"enable_push": true,
|
"enable_push": true,
|
||||||
"enable_push_whitelist": false,
|
"enable_push_whitelist": true,
|
||||||
"enable_force_push": true,
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
"enable_force_push_allowlist": true,
|
"enable_force_push": false,
|
||||||
"force_push_allowlist_usernames": ["jmiller"],
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
"enable_merge_whitelist": false,
|
"enable_merge_whitelist": false,
|
||||||
"required_approvals": 0,
|
"required_approvals": 0,
|
||||||
"block_on_rejected_reviews": false,
|
"block_on_rejected_reviews": false,
|
||||||
@@ -162,12 +165,13 @@ jobs:
|
|||||||
}'
|
}'
|
||||||
|
|
||||||
RULE_BETA='{
|
RULE_BETA='{
|
||||||
"rule_name": "beta/*",
|
"rule_name": "beta",
|
||||||
"enable_push": true,
|
"enable_push": true,
|
||||||
"enable_push_whitelist": false,
|
"enable_push_whitelist": true,
|
||||||
"enable_force_push": true,
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
"enable_force_push_allowlist": true,
|
"enable_force_push": false,
|
||||||
"force_push_allowlist_usernames": ["jmiller"],
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
"enable_merge_whitelist": false,
|
"enable_merge_whitelist": false,
|
||||||
"required_approvals": 0,
|
"required_approvals": 0,
|
||||||
"block_on_rejected_reviews": false,
|
"block_on_rejected_reviews": false,
|
||||||
@@ -175,12 +179,13 @@ jobs:
|
|||||||
}'
|
}'
|
||||||
|
|
||||||
RULE_ALPHA='{
|
RULE_ALPHA='{
|
||||||
"rule_name": "alpha/*",
|
"rule_name": "alpha",
|
||||||
"enable_push": true,
|
"enable_push": true,
|
||||||
"enable_push_whitelist": false,
|
"enable_push_whitelist": true,
|
||||||
"enable_force_push": true,
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
"enable_force_push_allowlist": true,
|
"enable_force_push": false,
|
||||||
"force_push_allowlist_usernames": ["jmiller"],
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
"enable_merge_whitelist": false,
|
"enable_merge_whitelist": false,
|
||||||
"required_approvals": 0,
|
"required_approvals": 0,
|
||||||
"block_on_rejected_reviews": false,
|
"block_on_rejected_reviews": false,
|
||||||
@@ -188,7 +193,7 @@ jobs:
|
|||||||
}'
|
}'
|
||||||
|
|
||||||
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
|
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 ──────────────────────────────
|
# ── Apply rules to each repo ──────────────────────────────
|
||||||
for REPO in $REPOS; do
|
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: |
|
run: |
|
||||||
API="${GITEA_URL}/api/v1"
|
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"
|
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||||
|
|
||||||
if [ -n "${{ inputs.repos }}" ]; then
|
if [ -n "${{ inputs.repos }}" ]; then
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: moko-platform.Release
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||||
|
# VERSION: 09.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 moko-platform tools
|
||||||
|
run: |
|
||||||
|
if ! command -v composer &> /dev/null; then
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
if [ -d "/opt/moko-platform/cli" ]; then
|
||||||
|
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
git clone --depth 1 --branch main --quiet \
|
||||||
|
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||||
|
/tmp/moko-platform-api
|
||||||
|
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||||
|
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Bump version
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/version_auto_bump.php \
|
||||||
|
--path . --branch "${GITHUB_REF_NAME}" \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
@@ -1,666 +1,285 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.Release
|
# INGROUP: moko-platform.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||||
# VERSION: 05.00.00
|
# VERSION: 05.00.00
|
||||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||||
#
|
#
|
||||||
# +========================================================================+
|
# +========================================================================+
|
||||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||||
# +========================================================================+
|
# +========================================================================+
|
||||||
# | |
|
# | |
|
||||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||||
# | |
|
# | |
|
||||||
# | Platform-specific: |
|
# | Platform-specific: |
|
||||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||||
# | generic: README-only, no update stream |
|
# | generic: README-only, no update stream |
|
||||||
# | |
|
# | |
|
||||||
# +========================================================================+
|
# +========================================================================+
|
||||||
|
|
||||||
name: "Universal: Build & Release"
|
name: "Universal: Build & Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [closed]
|
types: [opened, closed]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
workflow_dispatch:
|
||||||
- 'src/**'
|
inputs:
|
||||||
- 'htdocs/**'
|
action:
|
||||||
workflow_dispatch:
|
description: 'Action to perform'
|
||||||
|
required: false
|
||||||
env:
|
type: choice
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
default: release
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
options:
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
- release
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
- promote-rc
|
||||||
|
|
||||||
permissions:
|
env:
|
||||||
contents: write
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
jobs:
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
release:
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
name: Build & Release Pipeline
|
|
||||||
runs-on: release
|
permissions:
|
||||||
if: >-
|
contents: write
|
||||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
|
|
||||||
|
jobs:
|
||||||
steps:
|
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||||
- name: Checkout repository
|
promote-rc:
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
name: Promote to RC
|
||||||
with:
|
runs-on: release
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
if: >-
|
||||||
fetch-depth: 0
|
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
- name: Setup moko-platform tools
|
|
||||||
env:
|
steps:
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
- name: Checkout repository
|
||||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
|
with:
|
||||||
run: |
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
# Ensure PHP + Composer are available
|
fetch-depth: 1
|
||||||
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
|
- name: Setup moko-platform tools
|
||||||
fi
|
env:
|
||||||
git clone --depth 1 --branch main --quiet \
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
/tmp/moko-platform-api
|
run: |
|
||||||
cd /tmp/moko-platform-api
|
if ! command -v composer &> /dev/null; then
|
||||||
composer install --no-dev --no-interaction --quiet
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||||
# -- PLATFORM DETECTION ---------------------------------------------------
|
rm -rf /tmp/moko-platform-api
|
||||||
- name: Detect platform
|
git clone --depth 1 --branch main --quiet \
|
||||||
id: platform
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||||
run: |
|
/tmp/moko-platform-api
|
||||||
php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output
|
cd /tmp/moko-platform-api
|
||||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
composer install --no-dev --no-interaction --quiet
|
||||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1 || true)
|
|
||||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
- name: Rename branch to rc
|
||||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
run: |
|
||||||
|
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||||
- name: "Step 1: Read version"
|
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||||
id: version
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
run: |
|
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||||
VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path .)
|
--pr "${{ github.event.pull_request.number }}"
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
echo "::error::No VERSION in README.md"
|
- name: Checkout rc and configure git
|
||||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
run: |
|
||||||
exit 0
|
git fetch origin rc
|
||||||
fi
|
git checkout rc
|
||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
git config --local user.name "gitea-actions[bot]"
|
||||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
- name: Publish RC release
|
||||||
|
run: |
|
||||||
- name: "Step 1b: Bump version"
|
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||||
id: bump
|
--path . --stability rc --bump minor --branch rc \
|
||||||
if: steps.version.outputs.skip != 'true'
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
run: |
|
--skip-update-stream
|
||||||
MOKO_API="/tmp/moko-platform-api/cli"
|
|
||||||
BUMP=$(php ${MOKO_API}/version_bump.php --path . --minor)
|
- name: Summary
|
||||||
VERSION=$(echo "$BUMP" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true)
|
if: always()
|
||||||
[ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .)
|
run: |
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Bumped to: ${VERSION}"
|
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
- name: Check if already released
|
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||||
if: steps.version.outputs.skip != 'true'
|
release:
|
||||||
id: check
|
name: Build & Release Pipeline
|
||||||
run: |
|
runs-on: release
|
||||||
TAG="${{ steps.version.outputs.release_tag }}"
|
if: >-
|
||||||
BRANCH="${{ steps.version.outputs.branch }}"
|
github.event.pull_request.merged == true ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||||
TAG_EXISTS=false
|
|
||||||
BRANCH_EXISTS=false
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
|
with:
|
||||||
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
|
fetch-depth: 0
|
||||||
echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
|
|
||||||
|
- name: Configure git for bot pushes
|
||||||
# Tag and branch may persist across patch releases — never skip
|
run: |
|
||||||
echo "already_released=false" >> "$GITHUB_OUTPUT"
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
|
git config --local user.name "gitea-actions[bot]"
|
||||||
# -- SANITY CHECKS -------------------------------------------------------
|
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
- name: "Sanity: Pre-release validation"
|
|
||||||
if: >-
|
- name: Check for merge conflict markers
|
||||||
steps.version.outputs.skip != 'true' &&
|
run: |
|
||||||
steps.check.outputs.already_released != 'true'
|
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)
|
||||||
run: |
|
if [ -n "$CONFLICTS" ]; then
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
echo "::error::Merge conflict markers found — aborting release"
|
||||||
ERRORS=0
|
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
exit 1
|
||||||
echo "## Pre-Release Sanity Checks (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
fi
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "No conflict markers found"
|
||||||
|
|
||||||
# -- Version drift check (must pass before release) --------
|
- name: Setup moko-platform tools
|
||||||
README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
env:
|
||||||
if [ "$README_VER" != "$VERSION" ]; then
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
ERRORS=$((ERRORS+1))
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||||
else
|
run: |
|
||||||
echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
# Ensure PHP + Composer are available
|
||||||
fi
|
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
|
||||||
# Check CHANGELOG version matches
|
fi
|
||||||
CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
|
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||||
if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
|
rm -rf /tmp/moko-platform-api
|
||||||
echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
git clone --depth 1 --branch main --quiet \
|
||||||
ERRORS=$((ERRORS+1))
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||||
fi
|
/tmp/moko-platform-api
|
||||||
|
cd /tmp/moko-platform-api
|
||||||
# Check composer.json version if present
|
composer install --no-dev --no-interaction --quiet
|
||||||
if [ -f "composer.json" ]; then
|
|
||||||
COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
|
|
||||||
if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
|
- name: "Publish stable release"
|
||||||
echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
run: |
|
||||||
ERRORS=$((ERRORS+1))
|
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||||
fi
|
--path . --stability stable --bump minor --branch main \
|
||||||
fi
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
--skip-update-stream
|
||||||
# Common checks
|
|
||||||
if [ ! -f "LICENSE" ]; then
|
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||||
echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
|
- name: "Step 9: Mirror release to GitHub"
|
||||||
ERRORS=$((ERRORS+1))
|
if: >-
|
||||||
else
|
steps.version.outputs.skip != 'true' &&
|
||||||
echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
|
secrets.GH_MIRROR_TOKEN != ''
|
||||||
fi
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
else
|
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||||
echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
fi
|
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||||
|
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
# -- Platform-specific checks --------
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
case "$PLATFORM" in
|
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||||
joomla)
|
--branch main 2>&1 || true
|
||||||
if [ -n "$MANIFEST" ]; then
|
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||||
XML_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
|
||||||
if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
|
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||||
echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
- name: "Step 10: Push main to GitHub mirror"
|
||||||
ERRORS=$((ERRORS+1))
|
if: >-
|
||||||
else
|
steps.version.outputs.skip != 'true' &&
|
||||||
echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
secrets.GH_MIRROR_TOKEN != ''
|
||||||
fi
|
continue-on-error: true
|
||||||
TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
|
run: |
|
||||||
echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
|
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||||
else
|
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||||
echo "- No Joomla XML manifest (WaaS site)" >> $GITHUB_STEP_SUMMARY
|
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||||
fi ;;
|
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||||
dolibarr)
|
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||||
if [ -n "$MOD_FILE" ]; then
|
git fetch origin main --depth=1
|
||||||
MOD_VER=$(sed -n "s/.*\\\$this->version = '\([^']*\)'.*/\1/p" "$MOD_FILE" 2>/dev/null | head -1)
|
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||||
if [ -n "$MOD_VER" ] && [ "$MOD_VER" != "$VERSION" ]; then
|
&& echo "main branch pushed to GitHub mirror" \
|
||||||
echo "- Module drift: \`${MOD_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
|| echo "WARNING: GitHub mirror push failed"
|
||||||
ERRORS=$((ERRORS+1))
|
|
||||||
else
|
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||||
echo "- Module version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
if: steps.version.outputs.skip != 'true'
|
||||||
fi
|
continue-on-error: true
|
||||||
else
|
run: |
|
||||||
echo "- No mod*.class.php found" >> $GITHUB_STEP_SUMMARY
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
ERRORS=$((ERRORS+1))
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
fi
|
|
||||||
if [ ! -f "update.txt" ]; then
|
# Delete rc branch (ephemeral — created by promote-rc)
|
||||||
echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
ERRORS=$((ERRORS+1))
|
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||||
fi ;;
|
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||||
*) echo "- Generic platform � no manifest checks" >> $GITHUB_STEP_SUMMARY ;;
|
|
||||||
esac
|
# Delete dev branch
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||||
if [ "$ERRORS" -gt 0 ]; then
|
|
||||||
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
|
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||||
else
|
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||||
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
|
-H "Content-Type: application/json" \
|
||||||
fi
|
"${API_BASE}/branches" \
|
||||||
|
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||||
# -- STEP 2: Create or update version/XX.YY archive branch ---------------
|
|
||||||
# Always runs — every version change on main archives to version/XX.YY
|
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||||
- name: "Step 2: Version archive branch"
|
|
||||||
if: steps.check.outputs.already_released != 'true'
|
- name: "Step 12: Create version branch from main"
|
||||||
run: |
|
if: steps.version.outputs.skip != 'true'
|
||||||
BRANCH="${{ steps.version.outputs.branch }}"
|
continue-on-error: true
|
||||||
IS_MINOR="${{ steps.version.outputs.is_minor }}"
|
run: |
|
||||||
PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
# Check if branch exists
|
BRANCH_NAME="version/${VERSION}"
|
||||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
MAIN_SHA=$(git rev-parse HEAD)
|
||||||
git push origin HEAD:"$BRANCH" --force
|
|
||||||
echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
|
# Delete old version branch if it exists (same version re-release)
|
||||||
else
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||||
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
|
|
||||||
git push origin "$BRANCH" --force
|
# Create version/XX.YY.ZZ from main
|
||||||
echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||||
fi
|
|
||||||
|
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||||
# -- STEP 3: Set platform version ----------------------------------------
|
|
||||||
- name: "Step 3: Set platform version"
|
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true' &&
|
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||||
steps.check.outputs.already_released != 'true'
|
- name: "Post-release: Reset dev version"
|
||||||
run: |
|
if: steps.version.outputs.skip != 'true'
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
continue-on-error: true
|
||||||
php /tmp/moko-platform-api/cli/version_set_platform.php \
|
run: |
|
||||||
--path . --version "$VERSION" --branch main
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||||
# -- STEP 4: Update version badges ----------------------------------------
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||||
- name: "Step 4: Update version badges"
|
--branch dev --path . 2>&1 || true
|
||||||
if: steps.version.outputs.skip != 'true'
|
|
||||||
run: |
|
# -- Summary --------------------------------------------------------------
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
- name: Pipeline Summary
|
||||||
php /tmp/moko-platform-api/cli/badge_update.php --path . --version "${VERSION}" 2>/dev/null || true
|
if: always()
|
||||||
php /tmp/moko-platform-api/cli/version_check.php --path . --fix 2>/dev/null || true
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
- name: "Step 5: Write update stream"
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
if: >-
|
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||||
steps.version.outputs.skip != 'true' &&
|
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||||
steps.platform.outputs.platform == 'joomla'
|
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||||
run: |
|
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
# Fetch latest updates.xml from main so preserve logic has all channels
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||||
"${API}/contents/updates.xml?ref=main" 2>/dev/null | \
|
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||||
python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin)['content']).decode())" \
|
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
> updates.xml 2>/dev/null || true
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
php /tmp/moko-platform-api/cli/updates_xml_build.php \
|
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
--path . --version "${VERSION}" --stability stable \
|
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
fi
|
||||||
--github-output
|
|
||||||
|
|
||||||
- name: Commit release changes
|
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true' &&
|
|
||||||
steps.check.outputs.already_released != 'true'
|
|
||||||
run: |
|
|
||||||
if git diff --quiet && git diff --cached --quiet; then
|
|
||||||
echo "No changes to commit"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
# Set push URL with token for branch-protected repos
|
|
||||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore(release): build ${VERSION} [skip ci]" \
|
|
||||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
|
||||||
git push -u origin HEAD
|
|
||||||
|
|
||||||
# -- STEP 6: Create tag ---------------------------------------------------
|
|
||||||
- name: "Step 6: Create git tag"
|
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true'
|
|
||||||
run: |
|
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
|
||||||
# Only create the major release tag if it doesn't exist yet
|
|
||||||
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
|
|
||||||
git tag "$RELEASE_TAG"
|
|
||||||
git push origin "$RELEASE_TAG"
|
|
||||||
echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
# -- STEP 7: Create or update Gitea Release --------------------------------
|
|
||||||
- name: "Step 7: Gitea Release"
|
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true'
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
|
||||||
BRANCH="${{ steps.version.outputs.branch }}"
|
|
||||||
MAJOR="${{ steps.version.outputs.major }}"
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
|
|
||||||
# Reuse metadata from Step 5 (single source of truth)
|
|
||||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
|
||||||
EXT_NAME="${{ steps.updates.outputs.ext_name }}"
|
|
||||||
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
|
|
||||||
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
|
|
||||||
|
|
||||||
# Fallbacks if Step 5 was skipped
|
|
||||||
if [ -z "$EXT_ELEMENT" ]; then
|
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
|
||||||
fi
|
|
||||||
[ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
|
|
||||||
|
|
||||||
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
|
|
||||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
|
||||||
|
|
||||||
# Build release name: "Pretty Name VERSION (type_element-VERSION)"
|
|
||||||
# Strip existing type prefix to prevent duplication
|
|
||||||
EXT_ELEMENT=$(echo "$EXT_ELEMENT" | sed -E 's/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)//')
|
|
||||||
TYPE_PREFIX=""
|
|
||||||
case "${EXT_TYPE}" in
|
|
||||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
|
||||||
module) TYPE_PREFIX="mod_" ;;
|
|
||||||
component) TYPE_PREFIX="com_" ;;
|
|
||||||
template) TYPE_PREFIX="tpl_" ;;
|
|
||||||
library) TYPE_PREFIX="lib_" ;;
|
|
||||||
package) TYPE_PREFIX="pkg_" ;;
|
|
||||||
esac
|
|
||||||
RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
|
|
||||||
|
|
||||||
# Delete existing release if present (overwrite, not append)
|
|
||||||
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
|
||||||
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -n "$EXISTING_ID" ]; then
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true
|
|
||||||
echo "Deleted previous stable release (id: ${EXISTING_ID})"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create fresh release
|
|
||||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API_BASE}/releases" \
|
|
||||||
-d "$(python3 -c "import json; print(json.dumps({
|
|
||||||
'tag_name': '${RELEASE_TAG}',
|
|
||||||
'name': '${RELEASE_NAME}',
|
|
||||||
'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''',
|
|
||||||
'target_commitish': '${BRANCH}'
|
|
||||||
}))")"
|
|
||||||
echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
# -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
|
|
||||||
- name: "Step 8: Build package and update checksum"
|
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true'
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
|
||||||
REPO="${{ github.repository }}"
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
|
|
||||||
# All ZIPs upload to the major release tag (vXX)
|
|
||||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Find extension element name from manifest
|
|
||||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
|
||||||
[ -z "$MANIFEST" ] && exit 0
|
|
||||||
|
|
||||||
# Reuse element from Step 5, with same fallback chain
|
|
||||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
|
||||||
if [ -z "$EXT_ELEMENT" ]; then
|
|
||||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
|
||||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
|
||||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
|
||||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
|
||||||
fi
|
|
||||||
# ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
|
|
||||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
# For packages, prefer <packagename> over filename-derived element
|
|
||||||
if [ "$EXT_TYPE" = "package" ]; then
|
|
||||||
PKG_NAME=$(sed -n 's/.*<packagename>\([^<]*\)<\/packagename>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
|
||||||
[ -n "$PKG_NAME" ] && EXT_ELEMENT="$PKG_NAME"
|
|
||||||
fi
|
|
||||||
# Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
|
|
||||||
EXT_ELEMENT=$(echo "$EXT_ELEMENT" | sed -E 's/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)//')
|
|
||||||
TYPE_PREFIX=""
|
|
||||||
case "${EXT_TYPE}" in
|
|
||||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
|
||||||
module) TYPE_PREFIX="mod_" ;;
|
|
||||||
component) TYPE_PREFIX="com_" ;;
|
|
||||||
template) TYPE_PREFIX="tpl_" ;;
|
|
||||||
library) TYPE_PREFIX="lib_" ;;
|
|
||||||
package) TYPE_PREFIX="pkg_" ;;
|
|
||||||
esac
|
|
||||||
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
|
|
||||||
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
|
|
||||||
|
|
||||||
# -- Build install packages from src/ ----------------------------
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/"; exit 0; }
|
|
||||||
|
|
||||||
# ZIP package (type-aware via moko-platform PHP API)
|
|
||||||
php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --output /tmp
|
|
||||||
# Match the expected ZIP_NAME for upload
|
|
||||||
BUILT_ZIP=$(ls /tmp/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip 2>/dev/null | head -1 || true)
|
|
||||||
if [ -n "$BUILT_ZIP" ] && [ "$BUILT_ZIP" != "/tmp/${ZIP_NAME}" ]; then
|
|
||||||
mv "$BUILT_ZIP" "/tmp/${ZIP_NAME}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# tar.gz package (flat source archive)
|
|
||||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" --exclude='.ftpignore' --exclude='sftp-config*' --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
|
||||||
|
|
||||||
ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
|
|
||||||
TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
|
|
||||||
|
|
||||||
# -- Calculate SHA-256 for both ----------------------------------
|
|
||||||
SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
|
||||||
SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
|
||||||
|
|
||||||
# -- Get existing assets for cleanup --------------------------------
|
|
||||||
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
|
||||||
|
|
||||||
# -- Create per-file .sha256 checksum files -------------------------
|
|
||||||
echo "${SHA256_ZIP} ${ZIP_NAME}" > "/tmp/${ZIP_NAME}.sha256"
|
|
||||||
echo "${SHA256_TAR} ${TAR_NAME}" > "/tmp/${TAR_NAME}.sha256"
|
|
||||||
|
|
||||||
# -- Upload packages + checksums to release tag --------------------
|
|
||||||
for ASSET in "${ZIP_NAME}" "${TAR_NAME}" "${ZIP_NAME}.sha256" "${TAR_NAME}.sha256"; do
|
|
||||||
[ ! -f "/tmp/${ASSET}" ] && continue
|
|
||||||
# Delete existing asset with same name
|
|
||||||
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
|
||||||
import sys,json
|
|
||||||
assets = json.load(sys.stdin)
|
|
||||||
for a in assets:
|
|
||||||
if a['name'] == '${ASSET}':
|
|
||||||
print(a['id']); break
|
|
||||||
" 2>/dev/null || true)
|
|
||||||
[ -n "$ASSET_ID" ] && curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
|
||||||
# Upload
|
|
||||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary @"/tmp/${ASSET}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${ASSET}" > /dev/null 2>&1 || true
|
|
||||||
done
|
|
||||||
|
|
||||||
# updates.xml already handled by Step 5 (updates_xml_build.php with preserve logic)
|
|
||||||
|
|
||||||
echo "### Packages" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
# -- STEP 8b: Update release description with changelog ----------------------
|
|
||||||
- name: "Step 8b: Update release body"
|
|
||||||
if: steps.version.outputs.skip != 'true'
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
|
||||||
MOKO_CLI="/tmp/moko-platform-api/cli"
|
|
||||||
|
|
||||||
php ${MOKO_CLI}/release_body_update.php \
|
|
||||||
--path . --version "${VERSION}" --tag "${RELEASE_TAG}" \
|
|
||||||
--token "${{ secrets.GA_TOKEN }}" \
|
|
||||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
|
||||||
2>/dev/null || {
|
|
||||||
# Fallback: simple body update if CLI not available
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
|
|
||||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
|
||||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
|
||||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\nChecksum files attached as \`*.sha256\` assets."
|
|
||||||
curl -sf -X PATCH -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}" \
|
|
||||||
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
echo "Release body updated" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
|
||||||
- name: "Step 9: Mirror release to GitHub"
|
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true' &&
|
|
||||||
steps.version.outputs.stability == 'stable' &&
|
|
||||||
secrets.GH_TOKEN != ''
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
|
||||||
MAJOR="${{ steps.version.outputs.major }}"
|
|
||||||
BRANCH="${{ steps.version.outputs.branch }}"
|
|
||||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
|
||||||
|
|
||||||
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
|
|
||||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
|
||||||
echo "$NOTES" > /tmp/release_notes.md
|
|
||||||
|
|
||||||
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
|
|
||||||
|
|
||||||
if [ -z "$EXISTING" ]; then
|
|
||||||
gh release create "$RELEASE_TAG" \
|
|
||||||
--repo "$GH_REPO" \
|
|
||||||
--title "v${MAJOR} (latest: ${VERSION})" \
|
|
||||||
--notes-file /tmp/release_notes.md \
|
|
||||||
--target "$BRANCH" || true
|
|
||||||
else
|
|
||||||
gh release edit "$RELEASE_TAG" \
|
|
||||||
--repo "$GH_REPO" \
|
|
||||||
--title "v${MAJOR} (latest: ${VERSION})" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Upload assets to GitHub mirror
|
|
||||||
for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
|
|
||||||
if [ -f "$PKG" ]; then
|
|
||||||
_RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
|
|
||||||
[ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
|
||||||
- name: "Step 10: Push main to GitHub mirror"
|
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true' &&
|
|
||||||
secrets.GH_TOKEN != ''
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
|
||||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
|
||||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
|
||||||
git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
|
||||||
git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
|
||||||
git fetch origin main --depth=1
|
|
||||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
|
||||||
&& echo "main branch pushed to GitHub mirror" \
|
|
||||||
|| echo "WARNING: GitHub mirror push failed"
|
|
||||||
|
|
||||||
# -- Clean up lesser pre-releases (cascade) ---------------------------------
|
|
||||||
# stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev
|
|
||||||
- name: "Delete lesser pre-release channels"
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
php /tmp/moko-platform-api/cli/release_cascade.php \
|
|
||||||
--stability stable \
|
|
||||||
--token "${{ secrets.GA_TOKEN }}" \
|
|
||||||
--org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
|
||||||
--gitea-url "${GITEA_URL}" 2>/dev/null || true
|
|
||||||
|
|
||||||
- name: "Step 11: Delete and recreate dev branch from main"
|
|
||||||
if: steps.version.outputs.skip != 'true'
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
|
||||||
|
|
||||||
# Delete dev branch
|
|
||||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
|
||||||
|
|
||||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
|
||||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API_BASE}/branches" \
|
|
||||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
|
||||||
|
|
||||||
echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
|
|
||||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
|
||||||
- name: "Dolibarr: Reset dev version"
|
|
||||||
if: >-
|
|
||||||
steps.version.outputs.skip != 'true' &&
|
|
||||||
steps.platform.outputs.platform == 'dolibarr' &&
|
|
||||||
steps.platform.outputs.mod_file != ''
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
|
||||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
|
||||||
ENCODED_PATH=$(echo "$MOD_FILE" | sed 's|^\./||' | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))")
|
|
||||||
FILE_RESP=$(curl -sf -H "Authorization: token ${TOKEN}" "${API_BASE}/contents/${ENCODED_PATH}?ref=dev" 2>/dev/null || true)
|
|
||||||
FILE_SHA=$(echo "$FILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
|
||||||
FILE_CONTENT=$(echo "$FILE_RESP" | python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin).get('content','')).decode())" 2>/dev/null || true)
|
|
||||||
if [ -n "$FILE_SHA" ] && [ -n "$FILE_CONTENT" ]; then
|
|
||||||
UPDATED=$(echo "$FILE_CONTENT" | sed "s/\$this->version = '[^']*'/\$this->version = 'development'/")
|
|
||||||
ENCODED=$(echo "$UPDATED" | base64 -w0)
|
|
||||||
curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/contents/${ENCODED_PATH}" \
|
|
||||||
-d "$(jq -n --arg content \"$ENCODED\" --arg sha \"$FILE_SHA\" --arg msg \"chore(version): reset dev version [skip ci]\" --arg branch \"dev\" '{content:$content,sha:$sha,message:$msg,branch:$branch}')" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -- Summary --------------------------------------------------------------
|
|
||||||
- name: Pipeline Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
|
||||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
|
||||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
|
||||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
|
||||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -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>
|
# 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
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||||
#
|
name: "Cascade Main → Dev (DISABLED)"
|
||||||
# FILE INFORMATION
|
on: workflow_dispatch
|
||||||
# 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
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cascade:
|
noop:
|
||||||
name: Cascade main → branches
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: >-
|
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
|
||||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Discover target branches
|
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||||
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
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
# INGROUP: moko-platform.CI
|
# INGROUP: moko-platform.CI
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/ci-platform.yml
|
# PATH: /.gitea/workflows/ci-platform.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: moko-platform CI — the standards engine validates itself
|
# 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 |
|
# | This is NOT a generic CI workflow. This is the self-validation |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# INGROUP: moko-platform.Maintenance
|
# INGROUP: moko-platform.Maintenance
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/cleanup.yml
|
# PATH: /.gitea/workflows/cleanup.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||||
|
|
||||||
name: "Universal: Repository Cleanup"
|
name: "Universal: Repository Cleanup"
|
||||||
@@ -33,17 +33,17 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Delete merged branches
|
- name: Delete merged branches
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Merged Branch Cleanup ==="
|
echo "=== Merged Branch Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
|
||||||
# List branches via API
|
# 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')
|
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
# Check if branch is merged into main
|
# Check if branch is merged into main
|
||||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||||
echo " Deleting merged branch: ${BRANCH}"
|
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
|
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
fi
|
fi
|
||||||
@@ -66,20 +66,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Clean old workflow runs
|
- name: Clean old workflow runs
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Workflow Run Cleanup ==="
|
echo "=== Workflow Run Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
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)
|
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
|
# 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" | \
|
"${API}/actions/runs?status=completed&limit=50" | \
|
||||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
for RUN_ID in $RUNS; do
|
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
|
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
done
|
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
|
# INGROUP: moko-platform.Security
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
# PATH: /templates/workflows/gitleaks.yml.template
|
# PATH: /templates/workflows/gitleaks.yml.template
|
||||||
# VERSION: 01.00.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||||
#
|
#
|
||||||
# +========================================================================+
|
# +========================================================================+
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.Automation
|
# INGROUP: moko-platform.Automation
|
||||||
# VERSION: 01.00.00
|
# VERSION: 09.24.00
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create branch and comment
|
- name: Create branch and comment
|
||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# INGROUP: moko-platform.Notifications
|
# INGROUP: moko-platform.Notifications
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/notify.yml
|
# PATH: /.gitea/workflows/notify.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||||
|
|
||||||
name: "Universal: Notifications"
|
name: "Universal: Notifications"
|
||||||
|
|||||||
+508
-214
@@ -1,214 +1,508 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.CI
|
# INGROUP: moko-platform.CI
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||||
# VERSION: 05.00.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: PR gate — branch policy + code validation before merge
|
# BRIEF: PR gate — branch policy + code validation before merge
|
||||||
|
|
||||||
name: "Universal: PR Check"
|
name: "Universal: PR Check"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, edited]
|
types: [opened, synchronize, reopened, edited]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||||
branch-policy:
|
branch-policy:
|
||||||
name: Branch Policy
|
name: Branch Policy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check branch merge target
|
- name: Check branch merge target
|
||||||
run: |
|
run: |
|
||||||
HEAD="${{ github.head_ref }}"
|
HEAD="${{ github.head_ref }}"
|
||||||
BASE="${{ github.base_ref }}"
|
BASE="${{ github.base_ref }}"
|
||||||
|
|
||||||
echo "PR: ${HEAD} → ${BASE}"
|
echo "PR: ${HEAD} → ${BASE}"
|
||||||
|
|
||||||
ALLOWED=true
|
ALLOWED=true
|
||||||
REASON=""
|
REASON=""
|
||||||
|
|
||||||
case "$HEAD" in
|
case "$HEAD" in
|
||||||
feature/*|feat/*)
|
feature/*|feat/*)
|
||||||
if [ "$BASE" != "dev" ]; then
|
if [ "$BASE" != "dev" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
fix/*|bugfix/*)
|
fix/*|bugfix/*)
|
||||||
if [ "$BASE" != "dev" ]; then
|
if [ "$BASE" != "dev" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
hotfix/*)
|
patch/*)
|
||||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
alpha/*|beta/*)
|
hotfix/*)
|
||||||
if [ "$BASE" != "dev" ]; then
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Pre-release branches must target 'dev', not '${BASE}'"
|
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
rc/*)
|
rc)
|
||||||
if [ "$BASE" != "main" ]; then
|
if [ "$BASE" != "main" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
dev)
|
dev)
|
||||||
if [ "$BASE" != "main" ]; then
|
if [ "$BASE" != "main" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [ "$ALLOWED" = false ]; then
|
if [ "$ALLOWED" = false ]; then
|
||||||
echo "::error::${REASON}"
|
echo "::error::${REASON}"
|
||||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# ── Code Validation ────────────────────────────────────────────────────
|
# ── Code Validation ────────────────────────────────────────────────────
|
||||||
validate:
|
validate:
|
||||||
name: Validate PR
|
name: Validate PR
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Detect platform
|
- name: Check for merge conflict markers
|
||||||
id: platform
|
run: |
|
||||||
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)
|
||||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
if [ -n "$CONFLICTS" ]; then
|
||||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
echo "::error::Merge conflict markers found in source files"
|
||||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
|
||||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
- name: Setup PHP
|
exit 1
|
||||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
fi
|
||||||
run: |
|
echo "No conflict markers found"
|
||||||
if ! command -v php &> /dev/null; then
|
|
||||||
sudo apt-get update -qq
|
- name: Detect platform
|
||||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
id: platform
|
||||||
fi
|
run: |
|
||||||
|
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||||
- name: PHP syntax check
|
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||||
run: |
|
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||||
ERRORS=0
|
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||||
while IFS= read -r -d '' file; do
|
|
||||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
- name: Setup PHP
|
||||||
ERRORS=$((ERRORS + 1))
|
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||||
fi
|
run: |
|
||||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
if ! command -v php &> /dev/null; then
|
||||||
echo "PHP lint: ${ERRORS} error(s)"
|
sudo apt-get update -qq
|
||||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||||
|
fi
|
||||||
- name: Validate platform manifest
|
|
||||||
run: |
|
- name: PHP syntax check
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||||
case "$PLATFORM" in
|
run: |
|
||||||
joomla)
|
ERRORS=0
|
||||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
while IFS= read -r -d '' file; do
|
||||||
if [ -z "$MANIFEST" ]; then
|
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
ERRORS=$((ERRORS + 1))
|
||||||
exit 0
|
fi
|
||||||
fi
|
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||||
echo "Manifest: ${MANIFEST}"
|
echo "PHP lint: ${ERRORS} error(s)"
|
||||||
if command -v php &> /dev/null; then
|
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||||
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
|
- name: Joomla JEXEC guard check
|
||||||
for ELEMENT in name version description; do
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
run: |
|
||||||
done
|
ERRORS=0
|
||||||
echo "Joomla manifest valid"
|
while IFS= read -r -d '' file; do
|
||||||
;;
|
# Skip vendor, node_modules, and index.html stub files
|
||||||
dolibarr)
|
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
||||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
# Check first 10 lines for JEXEC or JPATH guard
|
||||||
if [ -z "$MOD_FILE" ]; then
|
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
|
||||||
echo "::error::No mod*.class.php found"
|
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||||
exit 1
|
ERRORS=$((ERRORS + 1))
|
||||||
fi
|
fi
|
||||||
echo "Dolibarr module: ${MOD_FILE}"
|
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 "Generic platform — no manifest validation"
|
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||||
;;
|
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||||
esac
|
exit 1
|
||||||
|
fi
|
||||||
- name: Check update stream format
|
echo "JEXEC guard: OK"
|
||||||
run: |
|
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
- name: Joomla directory listing protection
|
||||||
case "$PLATFORM" in
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
joomla)
|
run: |
|
||||||
if [ -f "updates.xml" ]; then
|
MISSING=0
|
||||||
if command -v php &> /dev/null; then
|
SOURCE_DIR="src"
|
||||||
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; }
|
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||||
fi
|
while IFS= read -r dir; do
|
||||||
echo "updates.xml valid"
|
if [ ! -f "${dir}/index.html" ]; then
|
||||||
fi
|
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
||||||
;;
|
MISSING=$((MISSING + 1))
|
||||||
dolibarr)
|
fi
|
||||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
||||||
;;
|
if [ "$MISSING" -gt 0 ]; then
|
||||||
esac
|
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
||||||
- name: Verify package source
|
fi
|
||||||
run: |
|
echo "Directory protection: ${MISSING} missing (advisory)"
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
- name: Joomla script file and asset checks
|
||||||
if [ ! -d "$SOURCE_DIR" ]; then
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
echo "::warning::No src/ or htdocs/ directory"
|
run: |
|
||||||
exit 0
|
ERRORS=0
|
||||||
fi
|
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
[ -z "$MANIFEST" ] && exit 0
|
||||||
echo "Source: ${FILE_COUNT} files"
|
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
|
||||||
|
# Check scriptfile exists if declared
|
||||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||||
pre-release:
|
if [ -n "$SCRIPTFILE" ]; then
|
||||||
name: Build RC Package
|
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||||
runs-on: ubuntu-latest
|
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
||||||
needs: [branch-policy, validate]
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
steps:
|
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
||||||
- name: Trigger RC pre-release
|
fi
|
||||||
env:
|
fi
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
|
||||||
REPO: ${{ github.repository }}
|
# Require joomla.asset.json and validate it
|
||||||
BRANCH: ${{ github.head_ref }}
|
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
if [ -z "$ASSET_JSON" ]; then
|
||||||
run: |
|
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
||||||
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\"}}"
|
ERRORS=$((ERRORS + 1))
|
||||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
else
|
||||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
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 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="src"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||||
|
if [ ! -d "$SOURCE_DIR" ]; then
|
||||||
|
echo "::warning::No src/ or htdocs/ directory"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||||
|
echo "Source: ${FILE_COUNT} files"
|
||||||
|
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||||
|
|
||||||
|
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||||
|
pre-release:
|
||||||
|
name: Build RC Package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [branch-policy, validate]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Trigger RC pre-release
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BRANCH: ${{ github.head_ref }}
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
run: |
|
||||||
|
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||||
|
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# ── 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."
|
||||||
|
|||||||
@@ -1,375 +1,184 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.Release
|
# INGROUP: moko-platform.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||||
# VERSION: 05.01.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||||
|
|
||||||
name: "Universal: Pre-Release"
|
name: "Universal: Pre-Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
pull_request:
|
||||||
inputs:
|
types: [closed]
|
||||||
stability:
|
branches:
|
||||||
description: 'Pre-release channel'
|
- dev
|
||||||
required: true
|
workflow_dispatch:
|
||||||
type: choice
|
inputs:
|
||||||
options:
|
stability:
|
||||||
- development
|
description: 'Pre-release channel'
|
||||||
- alpha
|
required: true
|
||||||
- beta
|
type: choice
|
||||||
- release-candidate
|
options:
|
||||||
|
- development
|
||||||
permissions:
|
- alpha
|
||||||
contents: write
|
- beta
|
||||||
|
- release-candidate
|
||||||
env:
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
permissions:
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
contents: write
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
|
||||||
|
env:
|
||||||
jobs:
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
build:
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
runs-on: release
|
|
||||||
|
jobs:
|
||||||
steps:
|
build:
|
||||||
- name: Checkout
|
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
|
||||||
uses: actions/checkout@v4
|
runs-on: release
|
||||||
with:
|
if: >-
|
||||||
fetch-depth: 0
|
github.event_name == 'workflow_dispatch' ||
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
|
||||||
|
|
||||||
- name: Setup tools
|
steps:
|
||||||
run: |
|
- name: Checkout
|
||||||
# Update moko-platform CLI tools if available; install PHP if missing
|
uses: actions/checkout@v4
|
||||||
if command -v moko-platform-update &> /dev/null; then
|
with:
|
||||||
moko-platform-update
|
fetch-depth: 0
|
||||||
elif [ -d "/opt/moko-platform" ]; then
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
cd /opt/moko-platform && git pull origin main --quiet 2>/dev/null || true
|
|
||||||
else
|
- name: Setup moko-platform tools
|
||||||
if ! command -v php &> /dev/null; then
|
env:
|
||||||
sudo apt-get update -qq
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
fi
|
run: |
|
||||||
git clone --depth 1 --branch main --quiet \
|
if ! command -v composer &> /dev/null; then
|
||||||
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
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
|
||||||
/tmp/moko-platform-api
|
fi
|
||||||
fi
|
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||||
# Set MOKO_CLI to whichever path exists
|
rm -rf /tmp/moko-platform-api
|
||||||
if [ -d "/opt/moko-platform/cli" ]; then
|
git clone --depth 1 --branch main --quiet \
|
||||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||||
else
|
/tmp/moko-platform-api
|
||||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||||
fi
|
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Detect platform
|
- name: Detect platform
|
||||||
id: platform
|
id: platform
|
||||||
run: |
|
run: |
|
||||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
|
||||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
- name: Resolve metadata and bump version
|
||||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
id: meta
|
||||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
run: |
|
||||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
STABILITY="${{ inputs.stability || 'development' }}"
|
||||||
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"
|
case "$STABILITY" in
|
||||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
development) TAG="development" ;;
|
||||||
|
alpha) TAG="alpha" ;;
|
||||||
- name: Resolve metadata and bump version
|
beta) TAG="beta" ;;
|
||||||
id: meta
|
release-candidate) TAG="release-candidate" ;;
|
||||||
run: |
|
esac
|
||||||
STABILITY="${{ inputs.stability }}"
|
|
||||||
|
# Bump version: patch for dev/alpha/beta, minor for RC
|
||||||
case "$STABILITY" in
|
case "$STABILITY" in
|
||||||
development) SUFFIX="-dev"; TAG="development" ;;
|
release-candidate) php ${MOKO_CLI}/version_bump.php --path . --minor 2>/dev/null || true ;;
|
||||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
*) php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true ;;
|
||||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
esac
|
||||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
|
||||||
esac
|
# Set stability suffix and fix consistency
|
||||||
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')
|
||||||
# Patch bump via CLI tool
|
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||||
php ${MOKO_CLI}/version_bump.php --path .
|
php ${MOKO_CLI}/version_set_platform.php \
|
||||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||||
TODAY=$(date +%Y-%m-%d)
|
|
||||||
|
# Read final version with suffix
|
||||||
# Update platform-specific manifest
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
|
||||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
# Commit version bump
|
||||||
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
php ${MOKO_CLI}/version_set_platform.php \
|
git config --local user.name "gitea-actions[bot]"
|
||||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
|
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
git add -A
|
||||||
# Commit version bump
|
git diff --cached --quiet || {
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||||
git config --local user.name "gitea-actions[bot]"
|
git push origin HEAD 2>&1
|
||||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
}
|
||||||
git add -A
|
|
||||||
git diff --cached --quiet || {
|
# Auto-detect element via manifest_element.php
|
||||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
php ${MOKO_CLI}/manifest_element.php \
|
||||||
git push origin HEAD 2>&1
|
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||||
}
|
--repo "${GITEA_REPO}" --github-output
|
||||||
|
|
||||||
# Auto-detect element (platform-aware)
|
# Read back element outputs
|
||||||
EXT_ELEMENT=""
|
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||||
case "$PLATFORM" in
|
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||||
joomla)
|
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||||
if [ -n "$MANIFEST" ]; then
|
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
|
||||||
if [ -z "$EXT_ELEMENT" ]; then
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||||
case "$EXT_ELEMENT" in
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
esac
|
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
|
||||||
else
|
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
|
||||||
fi
|
- name: Create release
|
||||||
;;
|
id: release
|
||||||
dolibarr)
|
run: |
|
||||||
if [ -n "$MOD_FILE" ]; then
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
else
|
php ${MOKO_CLI}/release_create.php \
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
--path . --version "$VERSION" --tag "$TAG" \
|
||||||
fi
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
;;
|
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||||
*)
|
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
- name: Build package and upload
|
||||||
;;
|
id: package
|
||||||
esac
|
run: |
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
php ${MOKO_CLI}/release_package.php \
|
||||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
--path . --version "$VERSION" --tag "$TAG" \
|
||||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
--repo "${GITEA_REPO}" --output /tmp || true
|
||||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
# updates.xml is generated dynamically by MokoGitea license server
|
||||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
# No need to build, commit, or sync updates.xml from workflows
|
||||||
|
|
||||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
- name: "Delete lesser pre-release channels (cascade)"
|
||||||
|
continue-on-error: true
|
||||||
- name: Build package
|
run: |
|
||||||
run: |
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
SOURCE_DIR="src"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
if [ ! -d "$SOURCE_DIR" ]; then
|
php ${MOKO_CLI}/release_cascade.php \
|
||||||
echo "::error::No src/ or htdocs/ directory"
|
--stability "${{ steps.meta.outputs.stability }}" \
|
||||||
exit 1
|
--token "${TOKEN}" \
|
||||||
fi
|
--api-base "${API_BASE}"
|
||||||
|
|
||||||
MANIFEST="${{ steps.meta.outputs.manifest }}"
|
- name: Summary
|
||||||
EXT_TYPE=""
|
if: always()
|
||||||
if [ -n "$MANIFEST" ]; then
|
run: |
|
||||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
fi
|
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||||
|
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||||
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
|
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||||
|
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||||
mkdir -p build/package
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||||
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
|
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "=== Building Joomla PACKAGE (multi-extension) ==="
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
|
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||||
[ ! -d "$ext_dir" ] && continue
|
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
EXT_NAME=$(basename "$ext_dir")
|
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
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
|
|
||||||
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)
|
|
||||||
|
|
||||||
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'
|
|
||||||
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 }}"
|
|
||||||
|
|
||||||
if [ ! -f "updates.xml" ]; then
|
|
||||||
echo "No updates.xml -- skipping"
|
|
||||||
exit 0
|
|
||||||
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
|
|
||||||
|
|
||||||
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"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: "Sync updates.xml to all branches"
|
|
||||||
if: steps.platform.outputs.platform == 'joomla'
|
|
||||||
run: |
|
|
||||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
|
|
||||||
for BRANCH in main dev; do
|
|
||||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
|
||||||
echo "Syncing updates.xml -> ${BRANCH}"
|
|
||||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
|
||||||
git checkout "origin/${BRANCH}" -- . 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
|
|
||||||
|
|
||||||
- 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 }}"
|
|
||||||
|
|
||||||
php ${MOKO_CLI}/release_cascade.php \
|
|
||||||
--stability "${{ steps.meta.outputs.stability }}" \
|
|
||||||
--token "${TOKEN}" \
|
|
||||||
--api-base "${API_BASE}"
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
|
||||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
|
||||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
|
||||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
|
||||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
# INGROUP: moko-platform.Security
|
# INGROUP: moko-platform.Security
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/security-audit.yml
|
# PATH: /.gitea/workflows/security-audit.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||||
|
|
||||||
name: "Universal: Security Audit"
|
name: "Universal: Security Audit"
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: moko-platform.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /templates/workflows/update-server.yml
|
||||||
|
# VERSION: 09.23.00
|
||||||
|
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
|
||||||
|
#
|
||||||
|
# Thin wrapper around moko-platform CLI tools.
|
||||||
|
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
|
||||||
|
#
|
||||||
|
# Joomla filters update entries by the user's "Minimum Stability" setting.
|
||||||
|
|
||||||
|
name: "Update Server"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'dev'
|
||||||
|
- 'dev/**'
|
||||||
|
- 'alpha/**'
|
||||||
|
- 'beta/**'
|
||||||
|
- 'rc/**'
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'htdocs/**'
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches:
|
||||||
|
- 'dev'
|
||||||
|
- 'dev/**'
|
||||||
|
- 'alpha/**'
|
||||||
|
- 'beta/**'
|
||||||
|
- 'rc/**'
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'htdocs/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
stability:
|
||||||
|
description: 'Stability tag'
|
||||||
|
required: true
|
||||||
|
default: 'development'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- development
|
||||||
|
- alpha
|
||||||
|
- beta
|
||||||
|
- rc
|
||||||
|
- stable
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-xml:
|
||||||
|
name: Update Server
|
||||||
|
runs-on: release
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup moko-platform tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
|
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
|
||||||
|
run: |
|
||||||
|
if ! command -v composer &> /dev/null; then
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||||
|
rm -rf /tmp/moko-platform
|
||||||
|
git clone --depth 1 --branch main --quiet \
|
||||||
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||||
|
/tmp/moko-platform 2>/dev/null || true
|
||||||
|
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||||
|
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Detect platform
|
||||||
|
id: platform
|
||||||
|
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||||
|
|
||||||
|
- name: Resolve stability and bump version
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
BRANCH="${{ github.ref_name }}"
|
||||||
|
|
||||||
|
# Configure git for bot pushes
|
||||||
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
|
git config --local user.name "gitea-actions[bot]"
|
||||||
|
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
|
||||||
|
# Determine stability from branch or manual input
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
STABILITY="${{ inputs.stability }}"
|
||||||
|
elif [[ "$BRANCH" == rc/* ]]; then
|
||||||
|
STABILITY="rc"
|
||||||
|
elif [[ "$BRANCH" == beta/* ]]; then
|
||||||
|
STABILITY="beta"
|
||||||
|
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||||
|
STABILITY="alpha"
|
||||||
|
else
|
||||||
|
STABILITY="development"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gitea release tag per stability
|
||||||
|
case "$STABILITY" in
|
||||||
|
development) TAG="development" ;;
|
||||||
|
alpha) TAG="alpha" ;;
|
||||||
|
beta) TAG="beta" ;;
|
||||||
|
rc) TAG="release-candidate" ;;
|
||||||
|
*) TAG="stable" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Bump patch, set platform suffix, fix consistency — version_bump preserves suffix
|
||||||
|
php ${MOKO_CLI}/version_set_platform.php \
|
||||||
|
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
|
||||||
|
--branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
|
||||||
|
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
|
||||||
|
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||||
|
|
||||||
|
# Read final version (includes suffix, e.g. 01.02.15-dev)
|
||||||
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Commit version bump if changed
|
||||||
|
git add -A
|
||||||
|
git diff --cached --quiet || {
|
||||||
|
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
|
||||||
|
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||||
|
git push
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Create release and upload package
|
||||||
|
id: package
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|
||||||
|
# Create or update Gitea release
|
||||||
|
php ${MOKO_CLI}/release_create.php \
|
||||||
|
--path . --version "$VERSION" --tag "$TAG" \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
|
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||||
|
|
||||||
|
# Build package and upload
|
||||||
|
php ${MOKO_CLI}/release_package.php \
|
||||||
|
--path . --version "$VERSION" --tag "$TAG" \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
|
--repo "${GITEA_REPO}" --output /tmp || true
|
||||||
|
|
||||||
|
- name: Update updates.xml
|
||||||
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||||
|
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||||
|
|
||||||
|
if [ ! -f "updates.xml" ]; then
|
||||||
|
echo "No updates.xml — skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHA_FLAG=""
|
||||||
|
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||||
|
|
||||||
|
php ${MOKO_CLI}/updates_xml_build.php \
|
||||||
|
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||||
|
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||||
|
${SHA_FLAG}
|
||||||
|
|
||||||
|
# Commit and push updates.xml
|
||||||
|
git add updates.xml
|
||||||
|
git diff --cached --quiet || {
|
||||||
|
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||||
|
git push
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Sync updates.xml to main
|
||||||
|
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
|
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||||
|
python3 -c "
|
||||||
|
import base64, json, urllib.request, sys
|
||||||
|
with open('updates.xml', 'rb') as f:
|
||||||
|
content = base64.b64encode(f.read()).decode()
|
||||||
|
payload = json.dumps({
|
||||||
|
'content': content,
|
||||||
|
'sha': '${FILE_SHA}',
|
||||||
|
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
|
||||||
|
'branch': 'main'
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
'${API_BASE}/contents/updates.xml',
|
||||||
|
data=payload, method='PUT',
|
||||||
|
headers={
|
||||||
|
'Authorization': 'token ${GITEA_TOKEN}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
print('updates.xml synced to main')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: SFTP deploy to dev server
|
||||||
|
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
||||||
|
env:
|
||||||
|
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||||
|
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||||
|
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||||
|
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||||
|
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||||
|
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||||
|
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
# Permission check: admin or maintain role required
|
||||||
|
ACTOR="${{ github.actor }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|
||||||
|
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||||
|
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||||
|
case "$PERMISSION" in
|
||||||
|
admin|maintain|write) ;;
|
||||||
|
*)
|
||||||
|
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||||
|
|
||||||
|
SOURCE_DIR="src"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||||
|
|
||||||
|
PORT="${DEV_PORT:-22}"
|
||||||
|
REMOTE="${DEV_PATH%/}"
|
||||||
|
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||||
|
|
||||||
|
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||||
|
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||||
|
if [ -n "$DEV_KEY" ]; then
|
||||||
|
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||||
|
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||||
|
else
|
||||||
|
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
|
||||||
|
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
|
||||||
|
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||||
|
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
|
||||||
|
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||||
|
fi
|
||||||
|
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||||
|
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||||
|
DISPLAY="${VERSION}"
|
||||||
|
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"generated_at": "2026-03-10T19:51:42.238134Z",
|
"generated_at": "2026-03-10T19:51:42.238134Z",
|
||||||
"repository": "mokoconsulting-tech/MokoStandards",
|
"repository": "MokoConsulting/moko-platform",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
},
|
},
|
||||||
"scripts": [
|
"scripts": [
|
||||||
|
|||||||
+15
-179
@@ -10,189 +10,25 @@ BRIEF: Release changelog
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
# 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]
|
## [Unreleased]
|
||||||
|
|
||||||
## [09.00.00] - 2026-05-26
|
|
||||||
|
|
||||||
### Added
|
## [09.24.00] --- 2026-06-04
|
||||||
- PHPDoc on Priority 1 Enterprise classes (CliFramework, adapters, ApiClient)
|
|
||||||
- Wiki: Coding-Standards page with PHPDoc standard, PHPCS exclusions, file patterns
|
## [09.23] --- 2026-05-31
|
||||||
- CI: PHPStan enforced at level 6 (was advisory), PHPUnit blocks on failure
|
|
||||||
|
## [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
|
### Fixed
|
||||||
- `updates_xml_build.php`: cascade entries down to lower channels — stable now writes all 5 entries instead of wiping them
|
- fix: auto-detect org/repo in updates_xml_build from manifest and git remote
|
||||||
- `updates_xml_build.php`: separate Joomla stability tags (`dev`, `rc`) from Gitea release tags (`development`, `release-candidate`) — download URLs now point to correct release assets
|
- fix: restore hyphen in version suffixes
|
||||||
- `updates_xml_build.php`: only emit `<client>site</client>` for templates and modules, not packages or components
|
- fix: release names use standardized format
|
||||||
- `updates_xml_build.php`: preservation logic matches Joomla tag names when deciding which existing entries to keep
|
- fix: remove lesser stream copies, each stream updates independently
|
||||||
|
- fix: sort updates.xml entries dev first, stable last
|
||||||
|
|
||||||
## [08.00.00] - 2026-05-26
|
## [09.21] --- 2026-05-30
|
||||||
|
|
||||||
### Changed
|
## [09.20] --- 2026-05-30
|
||||||
- PHPStan: level 5 → 6 (401 baselined, 0 new errors)
|
|
||||||
- Branch protection: 5 required checks enabled on main
|
|
||||||
- Workflows synced to all governed repos (72+ repos across 3 orgs)
|
|
||||||
- Flushed 44 stale runners from Gitea admin (3 active remain)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- PHPStan level 3→4: removed 13 dead properties, 41 defensive patterns baselined
|
|
||||||
- PHPStan level 4→5: fixed metrics `increment()` bug (labels passed as value param)
|
|
||||||
- PHPStan level 5→6: 360 missing array generic types baselined
|
|
||||||
|
|
||||||
## [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
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
## [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
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This file provides guidance to Claude Code when working with this repository.
|
|||||||
| **Language** | PHP 8.1+ |
|
| **Language** | PHP 8.1+ |
|
||||||
| **Default branch** | main |
|
| **Default branch** | main |
|
||||||
| **License** | GPL-3.0-or-later |
|
| **License** | GPL-3.0-or-later |
|
||||||
| **Version** | 06.00.00 |
|
| **Version** | 09.01.00 |
|
||||||
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
@@ -44,8 +44,7 @@ composer check
|
|||||||
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
||||||
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
||||||
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
||||||
| `definitions/` | Repository structure definitions (HCL format) |
|
| `templates/` | Universal templates, configs, governance schema |
|
||||||
| `templates/` | Workflow templates, config templates, docs templates |
|
|
||||||
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
||||||
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
|
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
|
||||||
|
|
||||||
|
|||||||
+161
-30
@@ -1,30 +1,161 @@
|
|||||||
# Contributing to moko-platform
|
# Contributing to Moko Consulting Projects
|
||||||
|
|
||||||
Thank you for your interest in contributing to the Moko Consulting platform.
|
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
|
||||||
|
|
||||||
## How to Contribute
|
## Branching Workflow
|
||||||
|
|
||||||
1. **Fork** the repository
|
```
|
||||||
2. Create a **feature branch** from `dev` (e.g., `feature/my-feature`)
|
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
|
||||||
3. Make your changes following [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
```
|
||||||
4. Submit a **Pull Request** targeting `dev`
|
|
||||||
|
### Step by step
|
||||||
## Branch Policy
|
|
||||||
|
1. **Create a feature branch** from `dev`:
|
||||||
- `feature/*`, `fix/*` branches target `dev`
|
```bash
|
||||||
- `hotfix/*` branches may target `dev` or `main`
|
git checkout dev && git pull
|
||||||
- `dev` merges to `main` for releases
|
git checkout -b feature/my-change
|
||||||
|
```
|
||||||
## Code Standards
|
|
||||||
|
2. **Work and commit** on your feature branch. Push to origin.
|
||||||
- PHP: follow PSR-12, use tabs for indentation
|
|
||||||
- All files must include the Moko copyright header and SPDX identifier
|
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
|
||||||
- Scripts must be self-contained (no external dependencies unless via composer)
|
|
||||||
|
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
|
||||||
## Reporting Issues
|
- This automatically renames the source branch to `rc` (release candidate)
|
||||||
|
- An RC pre-release is built and uploaded
|
||||||
Use the [issue tracker](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/issues) with the appropriate template.
|
|
||||||
|
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
|
||||||
*Moko Consulting <hello@mokoconsulting.tech>*
|
- 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>
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
FILE INFORMATION
|
FILE INFORMATION
|
||||||
DEFGROUP: MokoStandards.Root
|
DEFGROUP: MokoPlatform.Root
|
||||||
INGROUP: MokoStandards
|
INGROUP: MokoPlatform
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
PATH: /PLUGIN_SCRIPTS.md
|
PATH: /PLUGIN_SCRIPTS.md
|
||||||
BRIEF: Plugin system CLI documentation
|
BRIEF: Plugin system CLI documentation
|
||||||
@@ -11,7 +11,7 @@ BRIEF: Plugin system CLI documentation
|
|||||||
|
|
||||||
# Plugin System CLI Scripts
|
# 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
|
## Available Scripts
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
FILE INFORMATION
|
FILE INFORMATION
|
||||||
DEFGROUP: MokoStandards.Root
|
DEFGROUP: MokoPlatform.Root
|
||||||
INGROUP: MokoStandards
|
INGROUP: MokoPlatform
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
PATH: /README.md
|
PATH: /README.md
|
||||||
|
VERSION: 09.24.00
|
||||||
BRIEF: Project overview and documentation
|
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)
|
> **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)*
|
> **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>
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
FILE INFORMATION
|
FILE INFORMATION
|
||||||
DEFGROUP: MokoStandards.Index
|
DEFGROUP: MokoPlatform.Index
|
||||||
INGROUP: MokoStandards.Analysis
|
INGROUP: MokoPlatform.Analysis
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
PATH: /analysis/index.md
|
PATH: /analysis/index.md
|
||||||
BRIEF: Analysis directory index
|
BRIEF: Analysis directory index
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoPlatform.Automation
|
||||||
* INGROUP: MokoStandards.Scripts
|
* INGROUP: MokoPlatform.Scripts
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/bulk_joomla_template.php
|
* PATH: /automation/bulk_joomla_template.php
|
||||||
* BRIEF: Bulk scaffold and sync Joomla template repositories
|
* BRIEF: Bulk scaffold and sync Joomla template repositories
|
||||||
@@ -42,7 +42,7 @@ use MokoEnterprise\{
|
|||||||
*
|
*
|
||||||
* Provides three operations for Joomla template projects:
|
* Provides three operations for Joomla template projects:
|
||||||
* --scaffold: Create a new template repository with the full directory structure
|
* --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
|
* --list: List all repositories tagged as joomla-template
|
||||||
*
|
*
|
||||||
* Works with both GitHub and Gitea via the PlatformAdapterFactory.
|
* Works with both GitHub and Gitea via the PlatformAdapterFactory.
|
||||||
@@ -50,7 +50,7 @@ use MokoEnterprise\{
|
|||||||
class BulkJoomlaTemplate extends CliFramework
|
class BulkJoomlaTemplate extends CliFramework
|
||||||
{
|
{
|
||||||
public const DEFAULT_ORG = 'MokoConsulting';
|
public const DEFAULT_ORG = 'MokoConsulting';
|
||||||
public const VERSION = '04.06.10';
|
public const VERSION = '09.23.00';
|
||||||
|
|
||||||
private GitPlatformAdapter $adapter;
|
private GitPlatformAdapter $adapter;
|
||||||
private Config $config;
|
private Config $config;
|
||||||
@@ -318,7 +318,7 @@ class BulkJoomlaTemplate extends CliFramework
|
|||||||
$name,
|
$name,
|
||||||
$path,
|
$path,
|
||||||
$content,
|
$content,
|
||||||
"chore: update {$path} from MokoStandards",
|
"chore: update {$path} from moko-platform",
|
||||||
$existingSha,
|
$existingSha,
|
||||||
$branch
|
$branch
|
||||||
);
|
);
|
||||||
|
|||||||
+47
-42
@@ -9,8 +9,8 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoPlatform.Automation
|
||||||
* INGROUP: MokoStandards.Scripts
|
* INGROUP: MokoPlatform.Scripts
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/bulk_sync.php
|
* PATH: /automation/bulk_sync.php
|
||||||
* BRIEF: Enterprise-grade bulk repository synchronization
|
* BRIEF: Enterprise-grade bulk repository synchronization
|
||||||
@@ -42,7 +42,7 @@ use MokoEnterprise\{
|
|||||||
/**
|
/**
|
||||||
* Bulk Repository Synchronization Tool
|
* 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.
|
* the Enterprise library for robust, audited operations.
|
||||||
*/
|
*/
|
||||||
class BulkSync extends CliFramework
|
class BulkSync extends CliFramework
|
||||||
@@ -57,7 +57,7 @@ class BulkSync extends CliFramework
|
|||||||
* Script version number
|
* Script version number
|
||||||
* Public to allow script instantiation with class constants
|
* 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';
|
public const VERSION_MINOR = '04.05';
|
||||||
|
|
||||||
private ApiClient $api;
|
private ApiClient $api;
|
||||||
@@ -95,7 +95,7 @@ class BulkSync extends CliFramework
|
|||||||
*/
|
*/
|
||||||
protected function run(): int
|
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
|
// Initialize enterprise components
|
||||||
if (!$this->initializeComponents()) {
|
if (!$this->initializeComponents()) {
|
||||||
@@ -156,6 +156,11 @@ class BulkSync extends CliFramework
|
|||||||
return 0;
|
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
|
// Execute synchronization
|
||||||
$this->log("🔄 Starting synchronization...", 'INFO');
|
$this->log("🔄 Starting synchronization...", 'INFO');
|
||||||
$results = $this->executeSynchronization($org, $repositories, $alreadyProcessed);
|
$results = $this->executeSynchronization($org, $repositories, $alreadyProcessed);
|
||||||
@@ -175,7 +180,7 @@ class BulkSync extends CliFramework
|
|||||||
$results['health'] = $this->runHealthChecksAll($org, $repositories);
|
$results['health'] = $this->runHealthChecksAll($org, $repositories);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create/update tracking issue in MokoStandards
|
// Create/update tracking issue in moko-platform
|
||||||
$this->createSyncIssue($org, $results);
|
$this->createSyncIssue($org, $results);
|
||||||
|
|
||||||
// Create/update a failure issue when any repos failed
|
// Create/update a failure issue when any repos failed
|
||||||
@@ -239,7 +244,7 @@ class BulkSync extends CliFramework
|
|||||||
* Filter repositories based on include/exclude lists
|
* Filter repositories based on include/exclude lists
|
||||||
*/
|
*/
|
||||||
/** Repositories that are permanently excluded from bulk sync. */
|
/** 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
|
private function filterRepositories(array $repositories, array $include, array $exclude): array
|
||||||
{
|
{
|
||||||
@@ -421,7 +426,7 @@ class BulkSync extends CliFramework
|
|||||||
$this->log("", 'ERROR');
|
$this->log("", 'ERROR');
|
||||||
$this->log("Required Implementation:", 'ERROR');
|
$this->log("Required Implementation:", 'ERROR');
|
||||||
$this->log(" 1. Clone/fetch target repository", '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(" 3. Create pull request with changes", 'ERROR');
|
||||||
$this->log(" 4. Handle merge conflicts and validation", 'ERROR');
|
$this->log(" 4. Handle merge conflicts and validation", 'ERROR');
|
||||||
$this->log("", 'ERROR');
|
$this->log("", 'ERROR');
|
||||||
@@ -832,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
|
* Fetches existing labels first (GET) and only POSTs the ones that are
|
||||||
* missing. This avoids the 422 "already exists" responses that would
|
* missing. This avoids the 422 "already exists" responses that would
|
||||||
@@ -867,7 +872,7 @@ class BulkSync extends CliFramework
|
|||||||
|
|
||||||
// Workflow / Process
|
// Workflow / Process
|
||||||
['automation', '8B4513', 'Automated processes or scripts'],
|
['automation', '8B4513', 'Automated processes or scripts'],
|
||||||
['mokostandards', 'B60205', 'MokoStandards compliance'],
|
['moko-platform', 'B60205', 'moko-platform compliance'],
|
||||||
['needs-review', 'FBCA04', 'Awaiting code review'],
|
['needs-review', 'FBCA04', 'Awaiting code review'],
|
||||||
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
|
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
|
||||||
['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
|
['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
|
||||||
@@ -907,8 +912,8 @@ class BulkSync extends CliFramework
|
|||||||
['health: poor', 'FF6B6B', 'Health score below 50'],
|
['health: poor', 'FF6B6B', 'Health score below 50'],
|
||||||
|
|
||||||
// Sync / Automation (used by bulk_sync, scan_drift, check_repo_health)
|
// Sync / Automation (used by bulk_sync, scan_drift, check_repo_health)
|
||||||
['standards-update', 'B60205', 'MokoStandards sync update'],
|
['standards-update', 'B60205', 'moko-platform sync update'],
|
||||||
['standards-drift', 'FBCA04', 'Repository drifted from MokoStandards'],
|
['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'],
|
||||||
['sync-report', '0075CA', 'Bulk sync run report'],
|
['sync-report', '0075CA', 'Bulk sync run report'],
|
||||||
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
|
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
|
||||||
['push-failure', 'D73A4A', 'File push failure requiring attention'],
|
['push-failure', 'D73A4A', 'File push failure requiring attention'],
|
||||||
@@ -920,10 +925,10 @@ class BulkSync extends CliFramework
|
|||||||
['type: version', '0E8A16', 'Version-related change'],
|
['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.
|
// provisioned previously — skip the expensive full label provisioning.
|
||||||
try {
|
try {
|
||||||
$probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
$probe = $this->api->get("/repos/{$org}/{$repo}/labels/moko-platform");
|
||||||
if (!empty($probe['name'])) {
|
if (!empty($probe['name'])) {
|
||||||
return; // already provisioned
|
return; // already provisioned
|
||||||
}
|
}
|
||||||
@@ -1019,7 +1024,7 @@ class BulkSync extends CliFramework
|
|||||||
*/
|
*/
|
||||||
private function updateOpenBranches(string $org, string $repo): void
|
private function updateOpenBranches(string $org, string $repo): void
|
||||||
{
|
{
|
||||||
$syncBranchPrefix = 'chore/sync-mokostandards-';
|
$syncBranchPrefix = 'chore/sync-moko-platform-';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$defaultBranch = 'main';
|
$defaultBranch = 'main';
|
||||||
@@ -1050,7 +1055,7 @@ class BulkSync extends CliFramework
|
|||||||
$this->api->post("/repos/{$org}/{$repo}/merges", [
|
$this->api->post("/repos/{$org}/{$repo}/merges", [
|
||||||
'base' => $branch,
|
'base' => $branch,
|
||||||
'head' => $defaultBranch,
|
'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');
|
$this->log(" 🔀 Merged {$defaultBranch} → {$branch} (PR #{$prNum})", 'INFO');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -1071,7 +1076,7 @@ class BulkSync extends CliFramework
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Records which sync run touched the repo, the PR number, and the
|
* 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.
|
* trail of what was changed and why.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
@@ -1114,16 +1119,16 @@ class BulkSync extends CliFramework
|
|||||||
$minor = self::VERSION_MINOR;
|
$minor = self::VERSION_MINOR;
|
||||||
$force = isset($this->options['force']) ? ' *(--force)*' : '';
|
$force = isset($this->options['force']) ? ' *(--force)*' : '';
|
||||||
$prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber);
|
$prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber);
|
||||||
$source = $this->adapter->getRepoWebUrl($org, 'MokoStandards');
|
$source = $this->adapter->getRepoWebUrl($org, 'moko-platform');
|
||||||
$branchName = 'chore/sync-mokostandards-v' . $minor;
|
$branchName = 'chore/sync-moko-platform-v' . $minor;
|
||||||
$branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName);
|
$branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName);
|
||||||
|
|
||||||
$title = "chore: MokoStandards v{$minor} sync tracking";
|
$title = "chore: moko-platform v{$minor} sync tracking";
|
||||||
|
|
||||||
$body = <<<MD
|
$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 |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
@@ -1139,13 +1144,13 @@ class BulkSync extends CliFramework
|
|||||||
Protected files (README, CHANGELOG, GOVERNANCE, etc.) were not overwritten.
|
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;
|
MD;
|
||||||
|
|
||||||
// Dedent heredoc
|
// Dedent heredoc
|
||||||
$body = preg_replace('/^ /m', '', $body);
|
$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);
|
$labels = $this->resolveLabelIds($org, $repo, $labelNames);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1208,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
|
private function createSyncIssue(string $org, array $results): void
|
||||||
{
|
{
|
||||||
@@ -1227,7 +1232,7 @@ class BulkSync extends CliFramework
|
|||||||
$issues = $results['issues'] ?? [];
|
$issues = $results['issues'] ?? [];
|
||||||
|
|
||||||
// Stable title — no timestamp so repeated runs update a single issue
|
// 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'] ?? [];
|
$protection = $results['protection'] ?? [];
|
||||||
$hasProtect = !empty($protection);
|
$hasProtect = !empty($protection);
|
||||||
@@ -1276,7 +1281,7 @@ class BulkSync extends CliFramework
|
|||||||
: "|---|---|---|---|";
|
: "|---|---|---|---|";
|
||||||
|
|
||||||
$body = <<<MD
|
$body = <<<MD
|
||||||
## MokoStandards Bulk Sync Report
|
## moko-platform Bulk Sync Report
|
||||||
|
|
||||||
**Organisation:** `{$org}`
|
**Organisation:** `{$org}`
|
||||||
**Triggered:** {$now}{$force}
|
**Triggered:** {$now}{$force}
|
||||||
@@ -1296,7 +1301,7 @@ class BulkSync extends CliFramework
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Search for existing issue by label — any state so we can reopen closed ones
|
// 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',
|
'labels' => 'sync-report',
|
||||||
'state' => 'all',
|
'state' => 'all',
|
||||||
'per_page' => 1,
|
'per_page' => 1,
|
||||||
@@ -1304,8 +1309,8 @@ class BulkSync extends CliFramework
|
|||||||
'direction' => 'desc',
|
'direction' => 'desc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation'];
|
$labelNames = ['sync-report', 'moko-platform', 'type: chore', 'automation'];
|
||||||
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames);
|
$labels = $this->resolveLabelIds($org, 'moko-platform', $labelNames);
|
||||||
$existing = array_values($existing);
|
$existing = array_values($existing);
|
||||||
|
|
||||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||||
@@ -1314,22 +1319,22 @@ class BulkSync extends CliFramework
|
|||||||
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
||||||
$patch['state'] = 'open';
|
$patch['state'] = 'open';
|
||||||
}
|
}
|
||||||
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch);
|
$this->api->patch("/repos/{$org}/moko-platform/issues/{$issueNumber}", $patch);
|
||||||
try {
|
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) {
|
} catch (\Exception $le) {
|
||||||
/* non-fatal */
|
/* non-fatal */
|
||||||
}
|
}
|
||||||
$this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO');
|
$this->log("📋 Sync report issue updated: {$org}/moko-platform#{$issueNumber}", 'INFO');
|
||||||
} else {
|
} else {
|
||||||
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
$issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
'labels' => $labels,
|
'labels' => $labels,
|
||||||
'assignees' => ['jmiller'],
|
'assignees' => ['jmiller'],
|
||||||
]);
|
]);
|
||||||
$issueNumber = $issue['number'] ?? '?';
|
$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) {
|
} catch (\Exception $e) {
|
||||||
$this->log("⚠️ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN');
|
$this->log("⚠️ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN');
|
||||||
@@ -1337,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.
|
* Uses the 'sync-failure' label so it is distinct from the run-report issue.
|
||||||
* Reopens a closed issue rather than creating a duplicate.
|
* Reopens a closed issue rather than creating a duplicate.
|
||||||
*/
|
*/
|
||||||
@@ -1383,7 +1388,7 @@ class BulkSync extends CliFramework
|
|||||||
$body = preg_replace('/^ /m', '', $body);
|
$body = preg_replace('/^ /m', '', $body);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [
|
$existing = $this->api->get("/repos/{$org}/moko-platform/issues", [
|
||||||
'labels' => 'sync-failure',
|
'labels' => 'sync-failure',
|
||||||
'state' => 'all',
|
'state' => 'all',
|
||||||
'per_page' => 1,
|
'per_page' => 1,
|
||||||
@@ -1398,17 +1403,17 @@ class BulkSync extends CliFramework
|
|||||||
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
||||||
$patch['state'] = 'open';
|
$patch['state'] = 'open';
|
||||||
}
|
}
|
||||||
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch);
|
$this->api->patch("/repos/{$org}/moko-platform/issues/{$num}", $patch);
|
||||||
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN');
|
$this->log("🚨 Failure issue #{$num} updated: {$org}/moko-platform#{$num}", 'WARN');
|
||||||
} else {
|
} else {
|
||||||
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
$issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
'labels' => $this->resolveLabelIds($org, 'MokoStandards', ['sync-failure']),
|
'labels' => $this->resolveLabelIds($org, 'moko-platform', ['sync-failure']),
|
||||||
'assignees' => ['jmiller'],
|
'assignees' => ['jmiller'],
|
||||||
]);
|
]);
|
||||||
$num = $issue['number'] ?? '?';
|
$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) {
|
} catch (\Exception $e) {
|
||||||
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
|
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
|
||||||
|
|||||||
@@ -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
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoPlatform.Automation
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: MokoPlatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/enrich_mokostandards_xml.php
|
* PATH: /automation/enrich_mokostandards_xml.php
|
||||||
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
|
* 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
|
* Note: This script uses proc_open for shell commands. All arguments are escaped
|
||||||
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
|
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
|
||||||
*/
|
*/
|
||||||
@@ -27,448 +19,466 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
|
|
||||||
|
use MokoEnterprise\CliFramework;
|
||||||
use MokoEnterprise\MokoStandardsParser;
|
use MokoEnterprise\MokoStandardsParser;
|
||||||
|
|
||||||
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
|
class EnrichMokostandardsXmlCli extends CliFramework
|
||||||
$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
|
|
||||||
{
|
{
|
||||||
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
|
protected function configure(): void
|
||||||
if (!is_resource($proc)) {
|
{
|
||||||
return [1, "proc_open failed"];
|
$this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
|
||||||
}
|
$this->addArgument('--repo', 'Filter to a single repo name', '');
|
||||||
$stdout = stream_get_contents($pipes[1]);
|
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// composer.json
|
protected function run(): int
|
||||||
if (file_exists("{$workDir}/composer.json")) {
|
{
|
||||||
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
|
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
|
||||||
$phpReq = $composer['require']['php'] ?? null;
|
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
|
||||||
if ($phpReq) {
|
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
|
||||||
$build['runtime'] = "php:{$phpReq}";
|
|
||||||
|
$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 = [];
|
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
|
||||||
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
|
echo "Found " . count($repos) . " repositories\n\n";
|
||||||
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
|
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
|
||||||
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)) {
|
foreach ($repos as $repo) {
|
||||||
$enrichment['build'] = $build;
|
$name = $repo['name'];
|
||||||
}
|
if ($repoFilter && $name !== $repoFilter) {
|
||||||
|
|
||||||
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
$wc = file_get_contents($wf);
|
if (in_array($name, $skipRepos, true)) {
|
||||||
$t = ['name' => str_replace('deploy-', '', $dn)];
|
echo " {$name} ... SKIP (excluded)\n";
|
||||||
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
|
$stats['skipped']++;
|
||||||
$t['method'] = 'sftp';
|
continue;
|
||||||
} elseif (str_contains($wc, 'rsync')) {
|
|
||||||
$t['method'] = 'rsync';
|
|
||||||
}
|
}
|
||||||
if (str_contains($wc, 'src/')) {
|
if ($repo['archived'] ?? false) {
|
||||||
$t['src_dir'] = 'src/';
|
$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)) {
|
@rmdir($tmpBase);
|
||||||
$enrichment['deploy'] = $targets;
|
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scripts from Makefile + composer
|
private function inspectRepo(string $workDir, string $platform): array
|
||||||
$scripts = [];
|
{
|
||||||
if (file_exists("{$workDir}/Makefile")) {
|
$enrichment = [];
|
||||||
$mk = file_get_contents("{$workDir}/Makefile");
|
$build = [];
|
||||||
$known = [
|
|
||||||
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
|
if (is_dir("{$workDir}/src")) {
|
||||||
'clean' => 'build', 'package' => 'build',
|
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
|
||||||
'validate' => 'validate', 'release' => 'release',
|
$c = file_get_contents($xf);
|
||||||
];
|
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
|
||||||
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
|
$build['entry_point'] = 'src/' . basename($xf);
|
||||||
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
|
||||||
}
|
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
|
||||||
if (!empty($scripts)) {
|
break;
|
||||||
$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)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isset($b['package_type'])) {
|
|
||||||
$build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
|
if (file_exists("{$workDir}/composer.json")) {
|
||||||
}
|
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
|
||||||
if (isset($b['entry_point'])) {
|
$phpReq = $composer['require']['php'] ?? null;
|
||||||
$build->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
|
if ($phpReq) {
|
||||||
}
|
$build['runtime'] = "php:{$phpReq}";
|
||||||
if (isset($b['artifact'])) {
|
}
|
||||||
$art = $dom->createElementNS($ns, 'artifact');
|
|
||||||
foreach (['format','path','filename'] as $af) {
|
$deps = [];
|
||||||
if (isset($b['artifact'][$af])) {
|
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
|
||||||
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
|
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');
|
if (file_exists("{$workDir}/Makefile")) {
|
||||||
foreach ($b['dependencies'] as $d) {
|
$mk = file_get_contents("{$workDir}/Makefile");
|
||||||
$req = $dom->createElementNS($ns, 'requires', '');
|
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
|
||||||
$req->setAttribute('name', $d['name']);
|
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
|
||||||
if (isset($d['version'])) {
|
}
|
||||||
$req->setAttribute('version', $d['version']);
|
}
|
||||||
|
|
||||||
|
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'])) {
|
$wc = file_get_contents($wf);
|
||||||
$req->setAttribute('type', $d['type']);
|
$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($targets)) {
|
||||||
}
|
$enrichment['deploy'] = $targets;
|
||||||
|
|
||||||
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'])) {
|
$scripts = [];
|
||||||
$scriptsEl = $dom->createElementNS($ns, 'scripts');
|
if (file_exists("{$workDir}/Makefile")) {
|
||||||
foreach ($enrichment['scripts'] as $s) {
|
$mk = file_get_contents("{$workDir}/Makefile");
|
||||||
$script = $dom->createElementNS($ns, 'script');
|
$known = [
|
||||||
$script->setAttribute('name', $s['name']);
|
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
|
||||||
if (isset($s['phase'])) {
|
'clean' => 'build', 'package' => 'build',
|
||||||
$script->setAttribute('phase', $s['phase']);
|
'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 ─────────────────────────────────────────────────────────────────
|
$app = new EnrichMokostandardsXmlCli();
|
||||||
echo "=== MokoStandards XML Manifest Enrichment ===\n";
|
exit($app->execute());
|
||||||
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";
|
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
FILE INFORMATION
|
FILE INFORMATION
|
||||||
DEFGROUP: MokoStandards.Index
|
DEFGROUP: MokoPlatform.Index
|
||||||
INGROUP: MokoStandards.Automation
|
INGROUP: MokoPlatform.Automation
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
PATH: /automation/index.md
|
PATH: /automation/index.md
|
||||||
BRIEF: Automation directory index
|
BRIEF: Automation directory index
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoPlatform.Automation
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: MokoPlatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/migrate_to_gitea.php
|
* PATH: /automation/migrate_to_gitea.php
|
||||||
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
|
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
* USAGE
|
* USAGE
|
||||||
* php automation/migrate_to_gitea.php --dry-run
|
* php automation/migrate_to_gitea.php --dry-run
|
||||||
* php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
|
* 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
|
* php automation/migrate_to_gitea.php --resume
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ class MigrateToGitea extends CliFramework
|
|||||||
try {
|
try {
|
||||||
$this->gitea->createIssue(
|
$this->gitea->createIssue(
|
||||||
$giteaOrg,
|
$giteaOrg,
|
||||||
'MokoStandards',
|
'moko-platform',
|
||||||
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
|
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
|
||||||
$report,
|
$report,
|
||||||
['labels' => ['automation', 'type: chore']]
|
['labels' => ['automation', 'type: chore']]
|
||||||
|
|||||||
+47
-65
@@ -9,8 +9,8 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoPlatform.Automation
|
||||||
* INGROUP: MokoStandards.Scripts
|
* INGROUP: MokoPlatform.Scripts
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/push_files.php
|
* PATH: /automation/push_files.php
|
||||||
* BRIEF: Push one or more specific files to one or more remote repositories
|
* BRIEF: Push one or more specific files to one or more remote repositories
|
||||||
@@ -26,7 +26,6 @@ use MokoEnterprise\{
|
|||||||
AuditLogger,
|
AuditLogger,
|
||||||
CliFramework,
|
CliFramework,
|
||||||
Config,
|
Config,
|
||||||
DefinitionParser,
|
|
||||||
GitPlatformAdapter,
|
GitPlatformAdapter,
|
||||||
MetricsCollector,
|
MetricsCollector,
|
||||||
PlatformAdapterFactory,
|
PlatformAdapterFactory,
|
||||||
@@ -36,7 +35,7 @@ use MokoEnterprise\{
|
|||||||
/**
|
/**
|
||||||
* Targeted File Push Tool
|
* 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.
|
* more remote repositories — without running a full sync.
|
||||||
*
|
*
|
||||||
* Files are specified by their destination path as they appear in the target
|
* Files are specified by their destination path as they appear in the target
|
||||||
@@ -54,12 +53,11 @@ use MokoEnterprise\{
|
|||||||
class PushFiles extends CliFramework
|
class PushFiles extends CliFramework
|
||||||
{
|
{
|
||||||
public const DEFAULT_ORG = 'MokoConsulting';
|
public const DEFAULT_ORG = 'MokoConsulting';
|
||||||
public const VERSION = '04.06.00';
|
public const VERSION = '09.23.00';
|
||||||
|
|
||||||
private ApiClient $api;
|
private ApiClient $api;
|
||||||
private GitPlatformAdapter $adapter;
|
private GitPlatformAdapter $adapter;
|
||||||
private AuditLogger $logger;
|
private AuditLogger $logger;
|
||||||
private DefinitionParser $defParser;
|
|
||||||
private ProjectTypeDetector $typeDetector;
|
private ProjectTypeDetector $typeDetector;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +81,7 @@ class PushFiles extends CliFramework
|
|||||||
*/
|
*/
|
||||||
protected function run(): int
|
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()) {
|
if (!$this->initializeComponents()) {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -154,7 +152,6 @@ class PushFiles extends CliFramework
|
|||||||
$this->adapter = PlatformAdapterFactory::create($config);
|
$this->adapter = PlatformAdapterFactory::create($config);
|
||||||
$this->api = $this->adapter->getApiClient();
|
$this->api = $this->adapter->getApiClient();
|
||||||
$this->logger = new AuditLogger('push_files');
|
$this->logger = new AuditLogger('push_files');
|
||||||
$this->defParser = new DefinitionParser();
|
|
||||||
$this->typeDetector = new ProjectTypeDetector($this->logger);
|
$this->typeDetector = new ProjectTypeDetector($this->logger);
|
||||||
|
|
||||||
$platform = $this->adapter->getPlatformName();
|
$platform = $this->adapter->getPlatformName();
|
||||||
@@ -198,43 +195,24 @@ class PushFiles extends CliFramework
|
|||||||
$platform = $this->detectRepoPlatform($org, $repo);
|
$platform = $this->detectRepoPlatform($org, $repo);
|
||||||
$this->log(" {$repo}: platform = {$platform}", 'INFO');
|
$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 = [];
|
$resolved = [];
|
||||||
foreach ($files as $fileSpec) {
|
foreach ($files as $fileSpec) {
|
||||||
if (str_contains($fileSpec, ':')) {
|
if (str_contains($fileSpec, ':')) {
|
||||||
// Raw source:destination pair
|
// Raw source:destination pair
|
||||||
[$src, $dest] = explode(':', $fileSpec, 2);
|
[$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 {
|
} else {
|
||||||
// Destination path — look up in definition
|
// Same path as source and destination
|
||||||
$dest = ltrim($fileSpec, '/');
|
$src = $fileSpec;
|
||||||
if (isset($destToSource[$dest])) {
|
$dest = $fileSpec;
|
||||||
$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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
$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)) {
|
if (!empty($resolved)) {
|
||||||
@@ -246,24 +224,28 @@ class PushFiles extends CliFramework
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect platform for a repo by checking its sync def file, falling back
|
* Detect platform for a repo via manifest or live detection.
|
||||||
* to the live GitHub API detection used by bulk_sync.
|
|
||||||
*/
|
*/
|
||||||
private function detectRepoPlatform(string $org, string $repo): string
|
private function detectRepoPlatform(string $org, string $repo): string
|
||||||
{
|
{
|
||||||
// Check local sync def first — fastest path
|
// Read platform from repo's .mokogitea/manifest.xml via API
|
||||||
$defDir = dirname(__DIR__) . '/definitions/sync';
|
try {
|
||||||
$defFile = "{$defDir}/{$repo}.def.tf";
|
$manifestData = $this->adapter->getFileContent($org, $repo, '.mokogitea/manifest.xml', 'main');
|
||||||
if (file_exists($defFile)) {
|
if (!empty($manifestData)) {
|
||||||
$content = file_get_contents($defFile) ?: '';
|
$xml = @simplexml_load_string($manifestData);
|
||||||
if (preg_match('/detected_platform\s*=\s*"([^"]+)"/', $content, $m)) {
|
if ($xml !== false) {
|
||||||
return $m[1];
|
$platform = (string)($xml->governance->platform ?? '');
|
||||||
|
if (!empty($platform)) {
|
||||||
|
return $platform;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fall through to local detection
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to live detection
|
// Fall back to live detection
|
||||||
try {
|
try {
|
||||||
$repoData = $this->api->get("/repos/{$org}/{$repo}");
|
|
||||||
$result = $this->typeDetector->detect('.');
|
$result = $this->typeDetector->detect('.');
|
||||||
return $result['type'] ?? 'default';
|
return $result['type'] ?? 'default';
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -354,7 +336,7 @@ class PushFiles extends CliFramework
|
|||||||
|
|
||||||
$prNumber = null;
|
$prNumber = null;
|
||||||
if (!$direct) {
|
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);
|
$prBody = $this->buildPRBody($entries);
|
||||||
$pr = $this->adapter->createPullRequest(
|
$pr = $this->adapter->createPullRequest(
|
||||||
$org,
|
$org,
|
||||||
@@ -431,7 +413,7 @@ class PushFiles extends CliFramework
|
|||||||
|
|
||||||
$message = !empty($customMessage)
|
$message = !empty($customMessage)
|
||||||
? $customMessage
|
? $customMessage
|
||||||
: "chore: update {$destPath} from MokoStandards";
|
: "chore: update {$destPath} from moko-platform";
|
||||||
|
|
||||||
// Fetch existing file SHA (needed for updates)
|
// Fetch existing file SHA (needed for updates)
|
||||||
$existingSha = null;
|
$existingSha = null;
|
||||||
@@ -474,9 +456,9 @@ class PushFiles extends CliFramework
|
|||||||
): void {
|
): void {
|
||||||
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
||||||
$version = self::VERSION;
|
$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
|
$deliveryLine = $prNumber !== null
|
||||||
? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |"
|
? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |"
|
||||||
@@ -488,9 +470,9 @@ class PushFiles extends CliFramework
|
|||||||
));
|
));
|
||||||
|
|
||||||
$body = <<<MD
|
$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 |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
@@ -504,12 +486,12 @@ class PushFiles extends CliFramework
|
|||||||
{$fileRows}
|
{$fileRows}
|
||||||
|
|
||||||
---
|
---
|
||||||
*Generated automatically by [MokoStandards]({$source}) `push_files.php`*
|
*Generated automatically by [moko-platform]({$source}) `push_files.php`*
|
||||||
MD;
|
MD;
|
||||||
|
|
||||||
$body = preg_replace('/^ /m', '', $body);
|
$body = preg_replace('/^ /m', '', $body);
|
||||||
|
|
||||||
$labels = ['standards-update', 'mokostandards', 'type: chore', 'automation'];
|
$labels = ['standards-update', 'moko-platform', 'type: chore', 'automation'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$existing = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
$existing = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
||||||
@@ -567,7 +549,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.
|
* Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate.
|
||||||
*/
|
*/
|
||||||
private function createFailureIssue(string $org, array $results): void
|
private function createFailureIssue(string $org, array $results): void
|
||||||
@@ -615,7 +597,7 @@ class PushFiles extends CliFramework
|
|||||||
$body = preg_replace('/^ /m', '', $body);
|
$body = preg_replace('/^ /m', '', $body);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [
|
$existing = $this->api->get("/repos/{$org}/moko-platform/issues", [
|
||||||
'labels' => 'push-failure',
|
'labels' => 'push-failure',
|
||||||
'state' => 'all',
|
'state' => 'all',
|
||||||
'per_page' => 1,
|
'per_page' => 1,
|
||||||
@@ -630,17 +612,17 @@ class PushFiles extends CliFramework
|
|||||||
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
||||||
$patch['state'] = 'open';
|
$patch['state'] = 'open';
|
||||||
}
|
}
|
||||||
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch);
|
$this->api->patch("/repos/{$org}/moko-platform/issues/{$num}", $patch);
|
||||||
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN');
|
$this->log("🚨 Failure issue #{$num} updated: {$org}/moko-platform#{$num}", 'WARN');
|
||||||
} else {
|
} else {
|
||||||
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
$issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
'labels' => ['push-failure'],
|
'labels' => ['push-failure'],
|
||||||
'assignees' => ['jmiller'],
|
'assignees' => ['jmiller'],
|
||||||
]);
|
]);
|
||||||
$num = $issue['number'] ?? '?';
|
$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) {
|
} catch (\Exception $e) {
|
||||||
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
|
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
|
||||||
@@ -655,14 +637,14 @@ class PushFiles extends CliFramework
|
|||||||
private function buildPRBody(array $entries): string
|
private function buildPRBody(array $entries): string
|
||||||
{
|
{
|
||||||
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
$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) {
|
foreach ($entries as $entry) {
|
||||||
$lines[] = "- `{$entry['destination']}`";
|
$lines[] = "- `{$entry['destination']}`";
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'MokoStandards');
|
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'moko-platform');
|
||||||
$lines[] = "\n---\n*Generated by [MokoStandards]({$sourceUrl}) `push_files.php`*";
|
$lines[] = "\n---\n*Generated by [moko-platform]({$sourceUrl}) `push_files.php`*";
|
||||||
|
|
||||||
return implode("\n", $lines);
|
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
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoPlatform.Automation
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: MokoPlatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/push_mokostandards_xml.php
|
* PATH: /automation/push_mokostandards_xml.php
|
||||||
* BRIEF: Push XML manifests to all governed repositories
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
|
|
||||||
|
use MokoEnterprise\CliFramework;
|
||||||
use MokoEnterprise\MokoStandardsParser;
|
use MokoEnterprise\MokoStandardsParser;
|
||||||
|
|
||||||
// ── Configuration ────────────────────────────────────────────────────────
|
class PushMokostandardsXmlCli extends CliFramework
|
||||||
$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
|
|
||||||
{
|
{
|
||||||
global $CRM_PLATFORM_REPOS;
|
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
|
||||||
$name = $repo['name'] ?? '';
|
|
||||||
$nameLower = strtolower($name);
|
|
||||||
$description = strtolower($repo['description'] ?? '');
|
|
||||||
$topics = $repo['topics'] ?? [];
|
|
||||||
|
|
||||||
if (in_array($name, $CRM_PLATFORM_REPOS, true)) {
|
protected function configure(): void
|
||||||
return 'crm-platform';
|
{
|
||||||
}
|
$this->setDescription('Push XML manifests to all governed repositories');
|
||||||
if (in_array('dolibarr-platform', $topics)) {
|
$this->addArgument('--repo', 'Filter to a single repo name', '');
|
||||||
return 'crm-platform';
|
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
|
||||||
}
|
$this->addArgument('--force', 'Force overwrite even if already XML', false);
|
||||||
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'))) {
|
protected function run(): int
|
||||||
return 'joomla-template';
|
{
|
||||||
}
|
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
|
||||||
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
|
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
|
||||||
return 'waas-component';
|
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
|
||||||
}
|
|
||||||
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
|
|
||||||
return 'crm-module';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($description, 'joomla template')) {
|
$force = $this->getArgument('--force');
|
||||||
return 'joomla-template';
|
$repoFilter = $this->getArgument('--repo') ?: null;
|
||||||
}
|
$skipStr = $this->getArgument('--skip');
|
||||||
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
|
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
|
||||||
return 'waas-component';
|
|
||||||
}
|
|
||||||
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
|
|
||||||
return 'crm-module';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($nameLower, 'standard')) {
|
$parser = new MokoStandardsParser();
|
||||||
return 'standards-repository';
|
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
|
||||||
}
|
|
||||||
return 'default-repository';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
echo "=== moko-platform XML Manifest Push ===\n";
|
||||||
* Safe shell execution — uses proc_open with explicit arguments to avoid injection.
|
echo "Org: {$giteaOrg}\n";
|
||||||
* @return array{int, string}
|
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
|
||||||
*/
|
if ($repoFilter) {
|
||||||
function safeExec(string $command, string $cwd = '.'): array
|
echo "Filter: {$repoFilter}\n";
|
||||||
{
|
|
||||||
$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 "\n";
|
||||||
@rmdir($dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if (empty($token)) {
|
||||||
* Run a git command safely in a given working directory.
|
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
|
||||||
* @return array{int, string}
|
return 1;
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$batch = json_decode($body, true);
|
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
|
||||||
if (empty($batch)) {
|
echo "Found " . count($repos) . " repositories\n\n";
|
||||||
break;
|
|
||||||
|
$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 "\n=== Summary ===\n";
|
||||||
echo "=== MokoStandards XML Manifest Push ===\n";
|
echo "Created: {$stats['created']}\n";
|
||||||
echo "Org: {$giteaOrg}\n";
|
echo "Updated: {$stats['updated']}\n";
|
||||||
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
|
echo "Skipped: {$stats['skipped']}\n";
|
||||||
if ($repoFilter) {
|
echo "Failed: {$stats['failed']}\n";
|
||||||
echo "Filter: {$repoFilter}\n";
|
|
||||||
}
|
|
||||||
echo "\n";
|
|
||||||
|
|
||||||
if (empty($token)) {
|
return 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$platform = detectPlatform($repo);
|
private function detectPlatform(array $repo): string
|
||||||
$defaultBranch = $repo['default_branch'] ?? 'main';
|
{
|
||||||
// Prefer HTTPS with token (SSH port 2222 may be blocked); fall back to SSH
|
$name = $repo['name'] ?? '';
|
||||||
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
|
$nameLower = strtolower($name);
|
||||||
// Embed token in HTTPS URL for push auth
|
$description = strtolower($repo['description'] ?? '');
|
||||||
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
|
$topics = $repo['topics'] ?? [];
|
||||||
|
|
||||||
echo " {$name} [{$platform}] ... ";
|
if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
|
||||||
|
return 'crm-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('dolibarr-platform', $topics)) {
|
||||||
|
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('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;
|
* @return array{int, string}
|
||||||
$commitMsg = $isNew
|
*/
|
||||||
? 'chore: add XML .mokostandards manifest'
|
private function safeExec(string $command, string $cwd = '.'): array
|
||||||
: 'chore: update .mokostandards to XML format';
|
{
|
||||||
if (!empty($legacyDeleted)) {
|
$proc = proc_open(
|
||||||
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
|
$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]');
|
private function rmTree(string $dir): void
|
||||||
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
|
{
|
||||||
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
|
if (!is_dir($dir)) {
|
||||||
foreach ($legacyDeleted as $lf) {
|
return;
|
||||||
gitCmd($workDir, 'add', $lf);
|
}
|
||||||
|
$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')) {
|
* @return array{int, string}
|
||||||
echo "SKIP (no changes)\n";
|
*/
|
||||||
$stats['skipped']++;
|
private function gitCmd(string $workDir, string ...$args): array
|
||||||
rmTree($workDir);
|
{
|
||||||
continue;
|
$cmd = 'git';
|
||||||
}
|
foreach ($args as $a) {
|
||||||
if ($commitRet !== 0) {
|
$cmd .= ' ' . escapeshellarg($a);
|
||||||
echo "FAIL (commit)\n";
|
}
|
||||||
fprintf(STDERR, " %s\n", $commitOut);
|
return $this->safeExec($cmd, $workDir);
|
||||||
$stats['failed']++;
|
|
||||||
rmTree($workDir);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[$pushRet, $pushOut] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
|
private function fetchRepos(string $url, string $org, string $token): array
|
||||||
if ($pushRet !== 0) {
|
{
|
||||||
echo "FAIL (push)\n";
|
$repos = [];
|
||||||
fprintf(STDERR, " %s\n", $pushOut);
|
$page = 1;
|
||||||
$stats['failed']++;
|
do {
|
||||||
} else {
|
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
|
||||||
$action = $isNew ? 'CREATED' : 'UPDATED';
|
curl_setopt_array($ch, [
|
||||||
echo "{$action}\n";
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
$stats[$isNew ? 'created' : 'updated']++;
|
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||||
}
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
// Cleanup
|
if ($code !== 200) {
|
||||||
rmTree($workDir);
|
$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
|
$app = new PushMokostandardsXmlCli();
|
||||||
@rmdir($tmpBase);
|
exit($app->execute());
|
||||||
|
|
||||||
echo "\n=== Summary ===\n";
|
|
||||||
echo "Created: {$stats['created']}\n";
|
|
||||||
echo "Updated: {$stats['updated']}\n";
|
|
||||||
echo "Skipped: {$stats['skipped']}\n";
|
|
||||||
echo "Failed: {$stats['failed']}\n";
|
|
||||||
|
|||||||
+12
-12
@@ -9,8 +9,8 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoPlatform.Automation
|
||||||
* INGROUP: MokoStandards.Scripts
|
* INGROUP: MokoPlatform.Scripts
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/repo_cleanup.php
|
* PATH: /automation/repo_cleanup.php
|
||||||
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
|
* 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
|
class RepoCleanup extends CliFramework
|
||||||
{
|
{
|
||||||
private const VERSION = '04.06.00';
|
private const VERSION = '09.23.00';
|
||||||
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
|
private const SYNC_PREFIX = 'chore/sync-moko-platform-';
|
||||||
private const CURRENT_BRANCH = 'chore/sync-mokostandards-v04.02.00';
|
private const CURRENT_BRANCH = 'chore/sync-moko-platform-v04.02.00';
|
||||||
|
|
||||||
/** Workflow files that have been retired and should be deleted from governed repos. */
|
/** Workflow files that have been retired and should be deleted from governed repos. */
|
||||||
private const RETIRED_WORKFLOWS = [
|
private const RETIRED_WORKFLOWS = [
|
||||||
'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml',
|
'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml',
|
||||||
'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.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',
|
'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml',
|
||||||
'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml',
|
'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml',
|
||||||
'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml',
|
'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml',
|
||||||
@@ -98,7 +98,7 @@ class RepoCleanup extends CliFramework
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
$this->logMsg("🧹 moko-platform Repository Cleanup v" . self::VERSION);
|
||||||
$this->logMsg("Organization: {$org}");
|
$this->logMsg("Organization: {$org}");
|
||||||
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
|
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
@@ -225,7 +225,7 @@ class RepoCleanup extends CliFramework
|
|||||||
}
|
}
|
||||||
|
|
||||||
$allRepos = $this->adapter->listOrgRepos($org, $skipArchived);
|
$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 ──────────────────────────────────────────────
|
// ─── Cleanup operations ──────────────────────────────────────────────
|
||||||
@@ -463,9 +463,9 @@ class RepoCleanup extends CliFramework
|
|||||||
private function checkLabels(string $org, string $repo, array &$results): void
|
private function checkLabels(string $org, string $repo, array &$results): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
$this->api->get("/repos/{$org}/{$repo}/labels/moko-platform");
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logMsg(" ⚠️ Missing 'mokostandards' label");
|
$this->logMsg(" ⚠️ Missing 'moko-platform' label");
|
||||||
$results['labels_missing']++;
|
$results['labels_missing']++;
|
||||||
$this->api->resetCircuitBreaker();
|
$this->api->resetCircuitBreaker();
|
||||||
}
|
}
|
||||||
@@ -479,9 +479,9 @@ class RepoCleanup extends CliFramework
|
|||||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||||
$version = $m[1];
|
$version = $m[1];
|
||||||
|
|
||||||
// Check .mokostandards for the tracked MokoStandards version
|
// Check manifest.xml for the tracked moko-platform version
|
||||||
try {
|
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'] ?? '');
|
$mokoContent = base64_decode($mokoFile['content'] ?? '');
|
||||||
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
|
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
|
||||||
if ($vm[1] !== self::VERSION) {
|
if ($vm[1] !== self::VERSION) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# DEFGROUP: MokoStandards.Automation.ServerAutoheal
|
# DEFGROUP: MokoPlatform.Automation.ServerAutoheal
|
||||||
# INGROUP: MokoStandards.Automation
|
# INGROUP: MokoPlatform.Automation
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /automation/server-autoheal.sh
|
# PATH: /automation/server-autoheal.sh
|
||||||
# BRIEF: Server auto-heal on unclean restart + split system/content backups
|
# BRIEF: Server auto-heal on unclean restart + split system/content backups
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
@@ -18,26 +19,22 @@
|
|||||||
* php bin/moko <command> [options] (all platforms)
|
* php bin/moko <command> [options] (all platforms)
|
||||||
* ./bin/moko <command> [options] (Unix, after: chmod +x bin/moko)
|
* ./bin/moko <command> [options] (Unix, after: chmod +x bin/moko)
|
||||||
*
|
*
|
||||||
* COMMANDS
|
* COMMANDS (run `php bin/moko list` for the full list — 97 commands)
|
||||||
* sync Bulk-sync MokoStandards to organisation repos
|
|
||||||
* health Full repository health check (runs most validators)
|
|
||||||
* inventory Refresh docs/reference/REPOSITORY_INVENTORY.md
|
|
||||||
*
|
*
|
||||||
* check:syntax PHP syntax check (php -l) on all tracked .php files
|
* Automation sync, automation:cleanup, automation:migrate-gitea
|
||||||
* check:version Verify VERSION fields and badges match composer.json
|
* Validation health, detect, drift, check:syntax, check:version, ...
|
||||||
* check:changelog Validate CHANGELOG.md format
|
* Release release, release:joomla, release:create, release:publish, ...
|
||||||
* check:structure Verify required root files and directories
|
* Version version:read, version:bump, version:auto-bump, ...
|
||||||
* check:headers Check SPDX-License-Identifier presence in source files
|
* Build build:package, build:joomla, build:updates-xml, ...
|
||||||
* check:secrets Scan for leaked credentials / API keys
|
* Deploy deploy:joomla, deploy:dolibarr, deploy:sftp, deploy:rollback, ...
|
||||||
* check:tabs Detect tab characters in YAML files
|
* Repository repo:create, repo:archive, repo:rename-branch, repo:reset-dev, ...
|
||||||
* check:paths Detect backslash path separators in PHP source
|
* Bulk Operations bulk:push-workflow, bulk:push-manifest, bulk:template-joomla, ...
|
||||||
* check:xml Validate XML files are well-formed
|
* Maintenance maintenance:labels, maintenance:rotate-secrets, maintenance:pin-shas, ...
|
||||||
* check:enterprise Full enterprise-readiness check (headers, strict types, PSR-12)
|
* Fix fix:line-endings, fix:tabs, fix:trailing, fix:permissions
|
||||||
* check:dolibarr Validate Dolibarr module directory structure
|
* Monitoring dashboard, grafana, client:inventory, client:health-check
|
||||||
* check:joomla Validate Joomla XML manifest
|
* Platform platform:detect, manifest:read, manifest:element
|
||||||
* check:language Validate Joomla/Dolibarr .ini language files
|
* Wiki wiki:sync
|
||||||
* detect Auto-detect repository platform type
|
* Badges badge:update
|
||||||
* drift Scan org repos for drift from MokoStandards templates
|
|
||||||
*
|
*
|
||||||
* COMMON OPTIONS (passed through to each script)
|
* COMMON OPTIONS (passed through to each script)
|
||||||
* --path <dir> Repository root to check (default: .)
|
* --path <dir> Repository root to check (default: .)
|
||||||
@@ -87,11 +84,22 @@ require_once $autoloader;
|
|||||||
* All paths are relative to the repo root.
|
* All paths are relative to the repo root.
|
||||||
*/
|
*/
|
||||||
const COMMAND_MAP = [
|
const COMMAND_MAP = [
|
||||||
|
// Audit
|
||||||
|
'audit:query' => 'cli/audit_query.php',
|
||||||
|
|
||||||
// Automation
|
// 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
|
// 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
|
// Validation — general
|
||||||
'health' => 'validate/check_repo_health.php',
|
'health' => 'validate/check_repo_health.php',
|
||||||
@@ -107,11 +115,13 @@ const COMMAND_MAP = [
|
|||||||
'check:enterprise' => 'validate/check_enterprise_readiness.php',
|
'check:enterprise' => 'validate/check_enterprise_readiness.php',
|
||||||
|
|
||||||
// Validation — platform-specific
|
// Validation — platform-specific
|
||||||
'check:dolibarr' => 'validate/check_dolibarr_module.php',
|
'check:dolibarr' => 'validate/check_dolibarr_module.php',
|
||||||
'check:joomla' => 'validate/check_joomla_manifest.php',
|
'check:joomla' => 'validate/check_joomla_manifest.php',
|
||||||
'check:language' => 'validate/check_language_structure.php',
|
'check:joomla-compat' => 'cli/joomla_compat_check.php',
|
||||||
'check:client' => 'validate/check_client_theme.php',
|
'check:language' => 'validate/check_language_structure.php',
|
||||||
'check:wiki' => 'validate/check_wiki_health.php',
|
'check:client' => 'validate/check_client_theme.php',
|
||||||
|
'check:theme' => 'cli/theme_lint.php',
|
||||||
|
'check:wiki' => 'validate/check_wiki_health.php',
|
||||||
|
|
||||||
// Detection
|
// Detection
|
||||||
'detect' => 'validate/auto_detect_platform.php',
|
'detect' => 'validate/auto_detect_platform.php',
|
||||||
@@ -124,38 +134,94 @@ const COMMAND_MAP = [
|
|||||||
'release:notes' => 'cli/release_notes.php',
|
'release:notes' => 'cli/release_notes.php',
|
||||||
'release:validate' => 'cli/release_validate.php',
|
'release:validate' => 'cli/release_validate.php',
|
||||||
'release:cascade' => 'cli/release_cascade.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: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 management
|
||||||
'version:read' => 'cli/version_read.php',
|
'version:read' => 'cli/version_read.php',
|
||||||
'version:bump' => 'cli/version_bump.php',
|
'version:bump' => 'cli/version_bump.php',
|
||||||
|
'version:check' => 'cli/version_check.php',
|
||||||
'version:propagate' => 'maintenance/update_version_from_readme.php',
|
'version:propagate' => 'maintenance/update_version_from_readme.php',
|
||||||
'version:set-platform' => 'cli/version_set_platform.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
|
||||||
'build:package' => 'cli/package_build.php',
|
'build:package' => 'cli/package_build.php',
|
||||||
'build:joomla' => 'cli/joomla_build.php',
|
'build:joomla' => 'cli/joomla_build.php',
|
||||||
'build:updates-xml' => 'cli/updates_xml_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',
|
'platform:detect' => 'cli/platform_detect.php',
|
||||||
'manifest:read' => 'cli/manifest_read.php',
|
'manifest:read' => 'cli/manifest_read.php',
|
||||||
|
'manifest:element' => 'cli/manifest_element.php',
|
||||||
|
|
||||||
// Repository management
|
// Repository management
|
||||||
'repo:create' => 'cli/create_repo.php',
|
'repo:create' => 'cli/create_repo.php',
|
||||||
|
'repo:create-project' => 'cli/create_project.php',
|
||||||
'repo:archive' => 'cli/archive_repo.php',
|
'repo:archive' => 'cli/archive_repo.php',
|
||||||
'repo:scaffold-client' => 'cli/scaffold_client.php',
|
'repo:scaffold-client' => 'cli/scaffold_client.php',
|
||||||
'repo:provision' => 'cli/client_provision.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 operations
|
||||||
'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
|
'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
|
||||||
'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
|
'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
|
||||||
'bulk:sync-rulesets' => 'cli/sync_rulesets.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
|
// Monitoring & dashboards
|
||||||
'dashboard' => 'cli/client_dashboard.php',
|
'dashboard' => 'cli/client_dashboard.php',
|
||||||
'grafana' => 'cli/grafana_dashboard.php',
|
'grafana' => 'cli/grafana_dashboard.php',
|
||||||
'client:inventory' => 'cli/client_inventory.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
|
// Module validation
|
||||||
'validate:module' => 'bin/validate-module',
|
'validate:module' => 'bin/validate-module',
|
||||||
@@ -185,16 +251,28 @@ if ($command === 'list' || $command === 'commands') {
|
|||||||
|
|
||||||
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
// ── 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");
|
fwrite(STDERR, "Error: Unknown command '{$command}'\n\n");
|
||||||
printCommandList();
|
printCommandList();
|
||||||
exit(2);
|
exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
$scriptPath = $repoRoot . '/' . COMMAND_MAP[$command];
|
$scriptPath = $repoRoot . '/' . $scriptRelative;
|
||||||
|
|
||||||
if (!is_file($scriptPath)) {
|
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");
|
fwrite(STDERR, "Ensure the repository is complete and run: composer install\n");
|
||||||
exit(2);
|
exit(2);
|
||||||
}
|
}
|
||||||
@@ -256,6 +334,12 @@ function printCommandList(): void
|
|||||||
'bulk' => 'Bulk Operations',
|
'bulk' => 'Bulk Operations',
|
||||||
'client' => 'Client Management',
|
'client' => 'Client Management',
|
||||||
'validate' => 'Module Validation',
|
'validate' => 'Module Validation',
|
||||||
|
'deploy' => 'Deploy',
|
||||||
|
'fix' => 'Fix / Auto-remediation',
|
||||||
|
'maintenance' => 'Maintenance',
|
||||||
|
'automation' => 'Automation',
|
||||||
|
'badge' => 'Badges',
|
||||||
|
'wiki' => 'Wiki',
|
||||||
default => ucfirst($prefix),
|
default => ucfirst($prefix),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -265,6 +349,8 @@ function printCommandList(): void
|
|||||||
'health' => 'Validation',
|
'health' => 'Validation',
|
||||||
'detect', 'drift' => 'Validation',
|
'detect', 'drift' => 'Validation',
|
||||||
'dashboard', 'grafana' => 'Monitoring',
|
'dashboard', 'grafana' => 'Monitoring',
|
||||||
|
'release' => 'Release',
|
||||||
|
'license' => 'Licensing',
|
||||||
default => 'Other',
|
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
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -12,146 +13,157 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/archive_repo.php
|
* PATH: /cli/archive_repo.php
|
||||||
* BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
|
|
||||||
|
use MokoEnterprise\CliFramework;
|
||||||
use MokoEnterprise\Config;
|
use MokoEnterprise\Config;
|
||||||
use MokoEnterprise\PlatformAdapterFactory;
|
use MokoEnterprise\PlatformAdapterFactory;
|
||||||
|
|
||||||
$dryRun = in_array('--dry-run', $argv);
|
class ArchiveRepoCli extends CliFramework
|
||||||
$skipClose = in_array('--skip-close', $argv);
|
{
|
||||||
|
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 (empty($repoName)) {
|
||||||
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; }
|
$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) {
|
$app = new ArchiveRepoCli();
|
||||||
fwrite(STDERR, "Usage: php archive_repo.php --repo <RepoName> [--skip-close] [--dry-run]\n");
|
exit($app->execute());
|
||||||
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";
|
|
||||||
|
|||||||
@@ -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
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -10,59 +11,70 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/badge_update.php
|
* PATH: /cli/badge_update.php
|
||||||
* BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$version = null;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
|
||||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
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) {
|
$app = new BadgeUpdateCli();
|
||||||
fwrite(STDERR, "Usage: badge_update.php --path . --version XX.YY.ZZ\n");
|
exit($app->execute());
|
||||||
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);
|
|
||||||
|
|||||||
@@ -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.24.00
|
||||||
|
* 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
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/bulk_workflow_push.php
|
* PATH: /cli/bulk_workflow_push.php
|
||||||
* VERSION: 01.00.00
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
|
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
final class BulkWorkflowPush
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
{
|
|
||||||
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;
|
|
||||||
|
|
||||||
|
use MokoEnterprise\CliFramework;
|
||||||
|
|
||||||
|
class BulkWorkflowPushCli extends CliFramework
|
||||||
|
{
|
||||||
private int $updated = 0;
|
private int $updated = 0;
|
||||||
private int $created = 0;
|
private int $created = 0;
|
||||||
private int $skipped = 0;
|
private int $skipped = 0;
|
||||||
private int $errors = 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 === '') {
|
protected function run(): int
|
||||||
$this->log('ERROR: --token is required.');
|
{
|
||||||
$this->printUsage();
|
$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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->workflowFile === '') {
|
if ($workflowFile === '') {
|
||||||
$this->log('ERROR: --file is required.');
|
$this->log('ERROR', '--file is required.');
|
||||||
$this->printUsage();
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file_exists($this->workflowFile)) {
|
if (!file_exists($workflowFile)) {
|
||||||
$this->log("ERROR: File not found: {$this->workflowFile}");
|
$this->log('ERROR', "File not found: {$workflowFile}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->org === '') {
|
if ($org === '') {
|
||||||
$this->log('ERROR: --org is required.');
|
$this->log('ERROR', '--org is required.');
|
||||||
$this->printUsage();
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->destPath === '') {
|
if ($destPath === '') {
|
||||||
$this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile);
|
$destPath = '.mokogitea/workflows/' . basename($workflowFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
$localContent = file_get_contents($this->workflowFile);
|
$localContent = file_get_contents($workflowFile);
|
||||||
|
|
||||||
if ($localContent === false) {
|
if ($localContent === false) {
|
||||||
$this->log("ERROR: Could not read file: {$this->workflowFile}");
|
$this->log('ERROR', "Could not read file: {$workflowFile}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log("Pushing: {$this->workflowFile}");
|
$this->log('INFO', "Pushing: {$workflowFile}");
|
||||||
$this->log(" -> {$this->destPath} (branch: {$this->branch})");
|
$this->log('INFO', " -> {$destPath} (branch: {$branch})");
|
||||||
$this->log(" -> Org: {$this->org} @ {$this->giteaUrl}");
|
$this->log('INFO', " -> Org: {$org} @ {$giteaUrl}");
|
||||||
|
|
||||||
if ($this->dryRun) {
|
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) {
|
if ($repos === null) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\".");
|
$this->log('INFO', "Found " . count($repos) . " repo(s) in \"{$org}\".");
|
||||||
$this->log('');
|
echo "\n";
|
||||||
$this->log(sprintf('%-45s | %s', 'Repo', 'Status'));
|
fprintf(STDERR, "%-45s | %s\n", 'Repo', 'Status');
|
||||||
$this->log(str_repeat('-', 70));
|
fprintf(STDERR, "%s\n", str_repeat('-', 70));
|
||||||
|
|
||||||
$encodedContent = base64_encode($localContent);
|
$encodedContent = base64_encode($localContent);
|
||||||
|
|
||||||
foreach ($repos as $repo) {
|
foreach ($repos as $repo) {
|
||||||
$this->pushToRepo($repo, $encodedContent, $localContent);
|
$this->pushToRepo($giteaUrl, $token, $repo, $encodedContent, $localContent, $destPath, $branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('');
|
echo "\n";
|
||||||
$this->log("Done: {$this->created} created, {$this->updated} updated, "
|
$this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, "
|
||||||
. "{$this->skipped} skipped, {$this->errors} error(s).");
|
. "{$this->skipped} skipped, {$this->errors} error(s).");
|
||||||
|
|
||||||
return $this->errors > 0 ? 1 : 0;
|
return $this->errors > 0 ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pushToRepo(
|
private function pushToRepo(
|
||||||
|
string $giteaUrl,
|
||||||
|
string $token,
|
||||||
string $repoFullName,
|
string $repoFullName,
|
||||||
string $encodedContent,
|
string $encodedContent,
|
||||||
string $localContent
|
string $localContent,
|
||||||
|
string $destPath,
|
||||||
|
string $branch
|
||||||
): void {
|
): void {
|
||||||
[$owner, $repoName] = explode('/', $repoFullName, 2);
|
[$owner, $repoName] = explode('/', $repoFullName, 2);
|
||||||
|
|
||||||
$existing = $this->apiRequest(
|
$existing = $this->apiRequest(
|
||||||
|
$giteaUrl,
|
||||||
|
$token,
|
||||||
'GET',
|
'GET',
|
||||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||||
. "{$this->destPath}?ref={$this->branch}"
|
. "{$destPath}?ref={$branch}"
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($existing['code'] === 200) {
|
if ($existing['code'] === 200) {
|
||||||
@@ -124,21 +139,13 @@ final class BulkWorkflowPush
|
|||||||
$remoteContent = base64_decode($data['content'] ?? '');
|
$remoteContent = base64_decode($data['content'] ?? '');
|
||||||
|
|
||||||
if ($remoteContent === $localContent) {
|
if ($remoteContent === $localContent) {
|
||||||
$this->log(sprintf(
|
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'IDENTICAL (skipped)');
|
||||||
'%-45s | %s',
|
|
||||||
$repoFullName,
|
|
||||||
'IDENTICAL (skipped)'
|
|
||||||
));
|
|
||||||
$this->skipped++;
|
$this->skipped++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log(sprintf(
|
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD UPDATE');
|
||||||
'%-45s | %s',
|
|
||||||
$repoFullName,
|
|
||||||
'WOULD UPDATE'
|
|
||||||
));
|
|
||||||
$this->updated++;
|
$this->updated++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -146,100 +153,82 @@ final class BulkWorkflowPush
|
|||||||
$payload = json_encode([
|
$payload = json_encode([
|
||||||
'content' => $encodedContent,
|
'content' => $encodedContent,
|
||||||
'sha' => $remoteSha,
|
'sha' => $remoteSha,
|
||||||
'message' => "chore: sync {$this->destPath} "
|
'message' => "chore: sync {$destPath} "
|
||||||
. "from moko-platform [skip ci]",
|
. "from moko-platform [skip ci]",
|
||||||
'branch' => $this->branch,
|
'branch' => $branch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->apiRequest(
|
$response = $this->apiRequest(
|
||||||
|
$giteaUrl,
|
||||||
|
$token,
|
||||||
'PUT',
|
'PUT',
|
||||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||||
. $this->destPath,
|
. $destPath,
|
||||||
$payload
|
$payload
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($response['code'] === 200) {
|
if ($response['code'] === 200) {
|
||||||
$this->log(sprintf(
|
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'UPDATED');
|
||||||
'%-45s | %s',
|
|
||||||
$repoFullName,
|
|
||||||
'UPDATED'
|
|
||||||
));
|
|
||||||
$this->updated++;
|
$this->updated++;
|
||||||
} else {
|
} else {
|
||||||
$this->log(sprintf(
|
fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
|
||||||
'%-45s | %s',
|
|
||||||
$repoFullName,
|
|
||||||
"ERROR (HTTP {$response['code']})"
|
|
||||||
));
|
|
||||||
$this->errors++;
|
$this->errors++;
|
||||||
}
|
}
|
||||||
} elseif ($existing['code'] === 404) {
|
} elseif ($existing['code'] === 404) {
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log(sprintf(
|
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD CREATE');
|
||||||
'%-45s | %s',
|
|
||||||
$repoFullName,
|
|
||||||
'WOULD CREATE'
|
|
||||||
));
|
|
||||||
$this->created++;
|
$this->created++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = json_encode([
|
$payload = json_encode([
|
||||||
'content' => $encodedContent,
|
'content' => $encodedContent,
|
||||||
'message' => "chore: add {$this->destPath} "
|
'message' => "chore: add {$destPath} "
|
||||||
. "from moko-platform [skip ci]",
|
. "from moko-platform [skip ci]",
|
||||||
'branch' => $this->branch,
|
'branch' => $branch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->apiRequest(
|
$response = $this->apiRequest(
|
||||||
|
$giteaUrl,
|
||||||
|
$token,
|
||||||
'POST',
|
'POST',
|
||||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||||
. $this->destPath,
|
. $destPath,
|
||||||
$payload
|
$payload
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($response['code'] === 201) {
|
if ($response['code'] === 201) {
|
||||||
$this->log(sprintf(
|
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'CREATED');
|
||||||
'%-45s | %s',
|
|
||||||
$repoFullName,
|
|
||||||
'CREATED'
|
|
||||||
));
|
|
||||||
$this->created++;
|
$this->created++;
|
||||||
} else {
|
} else {
|
||||||
$this->log(sprintf(
|
fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
|
||||||
'%-45s | %s',
|
|
||||||
$repoFullName,
|
|
||||||
"ERROR (HTTP {$response['code']})"
|
|
||||||
));
|
|
||||||
$this->errors++;
|
$this->errors++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->log(sprintf(
|
fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$existing['code']})");
|
||||||
'%-45s | %s',
|
|
||||||
$repoFullName,
|
|
||||||
"ERROR (HTTP {$existing['code']})"
|
|
||||||
));
|
|
||||||
$this->errors++;
|
$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;
|
$page = 1;
|
||||||
$repos = [];
|
$repos = [];
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
$response = $this->apiRequest(
|
$response = $this->apiRequest(
|
||||||
|
$giteaUrl,
|
||||||
|
$token,
|
||||||
'GET',
|
'GET',
|
||||||
"/api/v1/orgs/{$this->org}/repos?"
|
"/api/v1/orgs/{$org}/repos?"
|
||||||
. "limit=50&page={$page}"
|
. "limit=50&page={$page}"
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($response['code'] < 200 || $response['code'] >= 300) {
|
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||||
if ($page === 1) {
|
if ($page === 1) {
|
||||||
$this->log("ERROR: Could not fetch repos "
|
$this->log('ERROR', "Could not fetch repos "
|
||||||
. "(HTTP {$response['code']}).");
|
. "(HTTP {$response['code']}).");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -271,76 +260,14 @@ final class BulkWorkflowPush
|
|||||||
return $repos;
|
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(
|
private function apiRequest(
|
||||||
|
string $giteaUrl,
|
||||||
|
string $token,
|
||||||
string $method,
|
string $method,
|
||||||
string $endpoint,
|
string $endpoint,
|
||||||
?string $body = null
|
?string $body = null
|
||||||
): array {
|
): array {
|
||||||
$url = $this->giteaUrl . $endpoint;
|
$url = $giteaUrl . $endpoint;
|
||||||
|
|
||||||
$ch = curl_init();
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_URL, $url);
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
@@ -349,7 +276,7 @@ final class BulkWorkflowPush
|
|||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
'Accept: application/json',
|
'Accept: application/json',
|
||||||
"Authorization: token {$this->token}",
|
"Authorization: token {$token}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($body !== null) {
|
if ($body !== null) {
|
||||||
@@ -376,12 +303,7 @@ final class BulkWorkflowPush
|
|||||||
|
|
||||||
return ['code' => $httpCode, 'body' => $responseBody];
|
return ['code' => $httpCode, 'body' => $responseBody];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
|
||||||
{
|
|
||||||
fwrite(STDERR, $message . PHP_EOL);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new BulkWorkflowPush();
|
$app = new BulkWorkflowPushCli();
|
||||||
exit($app->run());
|
exit($app->execute());
|
||||||
|
|||||||
+175
-249
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -11,309 +12,234 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/bulk_workflow_trigger.php
|
* PATH: /cli/bulk_workflow_trigger.php
|
||||||
* VERSION: 01.00.00
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Trigger a workflow across multiple repos at once
|
* BRIEF: Trigger a workflow across multiple repos at once
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
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 $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||||
private string $token = '';
|
private string $token = '';
|
||||||
private string $reposFile = '';
|
private string $reposFile = '';
|
||||||
private string $org = '';
|
private string $org = '';
|
||||||
private string $workflow = '';
|
private string $workflow = '';
|
||||||
private string $ref = 'main';
|
private string $ref = 'main';
|
||||||
private string $inputs = '';
|
private string $inputs = '';
|
||||||
private bool $dryRun = false;
|
|
||||||
|
|
||||||
public function run(): int
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->parseArgs();
|
$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 === '')
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$this->log('ERROR: --token is required.');
|
$this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||||
$this->printUsage();
|
$this->token = $this->getArgument('--token');
|
||||||
return 1;
|
$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 === '')
|
if ($this->token === '') {
|
||||||
{
|
$this->log('ERROR', '--token is required.');
|
||||||
$this->log('ERROR: --workflow is required.');
|
return 1;
|
||||||
$this->printUsage();
|
}
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->reposFile === '' && $this->org === '')
|
if ($this->workflow === '') {
|
||||||
{
|
$this->log('ERROR', '--workflow is required.');
|
||||||
$this->log('ERROR: Either --repos <file> or --org <org> is required.');
|
return 1;
|
||||||
$this->printUsage();
|
}
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build repo list
|
if ($this->reposFile === '' && $this->org === '') {
|
||||||
$repos = $this->buildRepoList();
|
$this->log('ERROR', 'Either --repos <file> or --org <org> is required.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
if ($repos === null || count($repos) === 0)
|
// Build repo list
|
||||||
{
|
$repos = $this->buildRepoList();
|
||||||
$this->log('ERROR: No repos found to process.');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s).");
|
if ($repos === null || count($repos) === 0) {
|
||||||
$this->log("Gitea URL: {$this->giteaUrl}");
|
$this->log('ERROR', 'No repos found to process.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->dryRun)
|
$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('[DRY RUN] No requests will be sent.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->log('');
|
if ($this->dryRun) {
|
||||||
|
$this->log('INFO', '[DRY RUN] No requests will be sent.');
|
||||||
|
}
|
||||||
|
|
||||||
// Parse inputs
|
$this->log('INFO', '');
|
||||||
$inputsDecoded = null;
|
|
||||||
|
|
||||||
if ($this->inputs !== '')
|
// Parse inputs
|
||||||
{
|
$inputsDecoded = null;
|
||||||
$inputsDecoded = json_decode($this->inputs, true);
|
|
||||||
|
|
||||||
if (!is_array($inputsDecoded))
|
if ($this->inputs !== '') {
|
||||||
{
|
$inputsDecoded = json_decode($this->inputs, true);
|
||||||
$this->log('ERROR: --inputs must be valid JSON.');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print header
|
if (!is_array($inputsDecoded)) {
|
||||||
$this->log(sprintf('%-40s | %s', 'Repo', 'Status'));
|
$this->log('ERROR', '--inputs must be valid JSON.');
|
||||||
$this->log(str_repeat('-', 60));
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$failCount = 0;
|
// Print header
|
||||||
|
$this->log('INFO', sprintf('%-40s | %s', 'Repo', 'Status'));
|
||||||
|
$this->log('INFO', str_repeat('-', 60));
|
||||||
|
|
||||||
foreach ($repos as $repo)
|
$failCount = 0;
|
||||||
{
|
|
||||||
$repo = trim($repo);
|
|
||||||
|
|
||||||
if ($repo === '' || strpos($repo, '/') === false)
|
foreach ($repos as $repo) {
|
||||||
{
|
$repo = trim($repo);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$owner, $repoName] = explode('/', $repo, 2);
|
if ($repo === '' || strpos($repo, '/') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->dryRun)
|
[$owner, $repoName] = explode('/', $repo, 2);
|
||||||
{
|
|
||||||
$this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = ['ref' => $this->ref];
|
if ($this->dryRun) {
|
||||||
|
$this->log('INFO', sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($inputsDecoded !== null)
|
$payload = ['ref' => $this->ref];
|
||||||
{
|
|
||||||
$payload['inputs'] = $inputsDecoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $this->apiRequest(
|
if ($inputsDecoded !== null) {
|
||||||
'POST',
|
$payload['inputs'] = $inputsDecoded;
|
||||||
"/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches",
|
}
|
||||||
json_encode($payload)
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
$response = $this->apiRequest(
|
||||||
{
|
'POST',
|
||||||
$status = 'TRIGGERED';
|
"/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches",
|
||||||
}
|
json_encode($payload)
|
||||||
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(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('INFO', sprintf('%-40s | %s', $repo, $status));
|
||||||
$this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
|
}
|
||||||
|
|
||||||
return $failCount > 0 ? 1 : 0;
|
$this->log('INFO', '');
|
||||||
}
|
$this->log('INFO', 'Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
|
||||||
|
|
||||||
private function parseArgs(): void
|
return $failCount > 0 ? 1 : 0;
|
||||||
{
|
}
|
||||||
$args = $_SERVER['argv'] ?? [];
|
|
||||||
$count = count($args);
|
|
||||||
|
|
||||||
for ($i = 1; $i < $count; $i++)
|
private function buildRepoList(): ?array
|
||||||
{
|
{
|
||||||
switch ($args[$i])
|
if ($this->reposFile !== '') {
|
||||||
{
|
if (!file_exists($this->reposFile)) {
|
||||||
case '--gitea-url':
|
$this->log('ERROR', "Repos file not found: {$this->reposFile}");
|
||||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
return null;
|
||||||
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 printUsage(): void
|
$content = file_get_contents($this->reposFile);
|
||||||
{
|
$lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool {
|
||||||
$this->log('Usage: bulk_workflow_trigger.php --token <token> --workflow <file> [options]');
|
return $line !== '' && $line[0] !== '#';
|
||||||
$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');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildRepoList(): ?array
|
return array_values($lines);
|
||||||
{
|
}
|
||||||
if ($this->reposFile !== '')
|
|
||||||
{
|
|
||||||
if (!file_exists($this->reposFile))
|
|
||||||
{
|
|
||||||
$this->log("ERROR: Repos file not found: {$this->reposFile}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = file_get_contents($this->reposFile);
|
// Fetch all repos from org
|
||||||
$lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool {
|
$this->log('INFO', "Fetching repos from org: {$this->org}");
|
||||||
return $line !== '' && $line[0] !== '#';
|
|
||||||
});
|
|
||||||
|
|
||||||
return array_values($lines);
|
$page = 1;
|
||||||
}
|
$repos = [];
|
||||||
|
|
||||||
// Fetch all repos from org
|
while (true) {
|
||||||
$this->log("Fetching repos from org: {$this->org}");
|
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
|
||||||
|
|
||||||
$page = 1;
|
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||||
$repos = [];
|
if ($page === 1) {
|
||||||
|
$this->log('ERROR', "Could not fetch repos for org (HTTP {$response['code']}).");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
while (true)
|
break;
|
||||||
{
|
}
|
||||||
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
|
|
||||||
|
|
||||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
$data = json_decode($response['body'], true);
|
||||||
{
|
|
||||||
if ($page === 1)
|
|
||||||
{
|
|
||||||
$this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']}).");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
if ($fullName !== '') {
|
||||||
{
|
$repos[] = $fullName;
|
||||||
break;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($data as $repo)
|
$page++;
|
||||||
{
|
}
|
||||||
$fullName = $repo['full_name'] ?? '';
|
|
||||||
|
|
||||||
if ($fullName !== '')
|
$this->log('INFO', 'Found ' . count($repos) . " repo(s) in org \"{$this->org}\".");
|
||||||
{
|
|
||||||
$repos[] = $fullName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$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
|
if ($body !== null) {
|
||||||
{
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
$url = $this->giteaUrl . $endpoint;
|
}
|
||||||
|
|
||||||
$ch = curl_init();
|
$responseBody = curl_exec($ch);
|
||||||
curl_setopt($ch, CURLOPT_URL, $url);
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
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)
|
if (curl_errno($ch)) {
|
||||||
{
|
$error = curl_error($ch);
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
curl_close($ch);
|
||||||
}
|
|
||||||
|
|
||||||
$responseBody = curl_exec($ch);
|
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
}
|
||||||
|
|
||||||
if (curl_errno($ch))
|
curl_close($ch);
|
||||||
{
|
|
||||||
$error = curl_error($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
return ['code' => $httpCode, 'body' => $responseBody];
|
||||||
}
|
}
|
||||||
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
return ['code' => $httpCode, 'body' => $responseBody];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function log(string $message): void
|
|
||||||
{
|
|
||||||
fwrite(STDERR, $message . PHP_EOL);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new BulkWorkflowTrigger();
|
$app = new BulkWorkflowTriggerCli();
|
||||||
exit($app->run());
|
exit($app->execute());
|
||||||
|
|||||||
+74
-63
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -10,73 +11,83 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/changelog_promote.php
|
* PATH: /cli/changelog_promote.php
|
||||||
* BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$version = null;
|
|
||||||
$date = date('Y-m-d');
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
|
||||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
class ChangelogPromoteCli extends CliFramework
|
||||||
if ($arg === '--date' && isset($argv[$i + 1])) $date = $argv[$i + 1];
|
{
|
||||||
|
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) {
|
$app = new ChangelogPromoteCli();
|
||||||
fwrite(STDERR, "Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]\n");
|
exit($app->execute());
|
||||||
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);
|
|
||||||
|
|||||||
@@ -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
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/client_dashboard.php
|
* PATH: /cli/client_dashboard.php
|
||||||
* VERSION: 01.00.00
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Generate unified client dashboard HTML
|
* BRIEF: Generate unified client dashboard HTML
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
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 $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||||
private string $token = '';
|
private string $token = '';
|
||||||
@@ -29,29 +33,47 @@ final class ClientDashboard
|
|||||||
private int $sslWarnDays = 30;
|
private int $sslWarnDays = 30;
|
||||||
private int $httpTimeout = 10;
|
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 === '') {
|
if ($this->token === '') {
|
||||||
$this->token = getenv('GA_TOKEN') ?: '';
|
$this->token = getenv('MOKOGITEA_TOKEN') ?: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->token === '') {
|
if ($this->token === '') {
|
||||||
$this->log('ERROR: --token or GA_TOKEN required.');
|
$this->log('ERROR', '--token or MOKOGITEA_TOKEN required.');
|
||||||
$this->printUsage();
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('Gathering client data...');
|
$this->log('INFO', 'Gathering client data...');
|
||||||
$clients = $this->discoverClients();
|
$clients = $this->discoverClients();
|
||||||
|
|
||||||
if ($clients === null) {
|
if ($clients === null) {
|
||||||
$this->log('ERROR: Could not fetch client repos.');
|
$this->log('ERROR', 'Could not fetch client repos.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('Found ' . count($clients) . ' client(s).');
|
$this->log('INFO', 'Found ' . count($clients) . ' client(s).');
|
||||||
|
|
||||||
foreach ($clients as &$client) {
|
foreach ($clients as &$client) {
|
||||||
$this->enrichClient($client);
|
$this->enrichClient($client);
|
||||||
@@ -63,7 +85,7 @@ final class ClientDashboard
|
|||||||
|
|
||||||
if ($this->outputFile !== '') {
|
if ($this->outputFile !== '') {
|
||||||
file_put_contents($this->outputFile, $html);
|
file_put_contents($this->outputFile, $html);
|
||||||
$this->log("Dashboard: {$this->outputFile}");
|
$this->log('INFO', "Dashboard: {$this->outputFile}");
|
||||||
} else {
|
} else {
|
||||||
fwrite(STDOUT, $html);
|
fwrite(STDOUT, $html);
|
||||||
}
|
}
|
||||||
@@ -151,9 +173,8 @@ final class ClientDashboard
|
|||||||
private function enrichClient(array &$client): void
|
private function enrichClient(array &$client): void
|
||||||
{
|
{
|
||||||
$repo = $client['repo'];
|
$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");
|
$resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables");
|
||||||
$vars = [];
|
$vars = [];
|
||||||
|
|
||||||
@@ -185,7 +206,6 @@ final class ClientDashboard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSL
|
|
||||||
$client['ssl_expiry'] = null;
|
$client['ssl_expiry'] = null;
|
||||||
$client['ssl_days'] = null;
|
$client['ssl_days'] = null;
|
||||||
$client['ssl_status'] = 'unknown';
|
$client['ssl_status'] = 'unknown';
|
||||||
@@ -212,7 +232,6 @@ final class ClientDashboard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last release
|
|
||||||
$client['last_release'] = '';
|
$client['last_release'] = '';
|
||||||
$client['last_release_date'] = '';
|
$client['last_release_date'] = '';
|
||||||
$relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1");
|
$relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1");
|
||||||
@@ -461,69 +480,7 @@ CARD;
|
|||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
return ['code' => $code, 'body' => $body];
|
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();
|
$app = new ClientDashboardCli();
|
||||||
exit($app->run());
|
exit($app->execute());
|
||||||
|
|||||||
+198
-188
@@ -1,188 +1,198 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
*
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
*
|
||||||
*
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
* FILE INFORMATION
|
*
|
||||||
* DEFGROUP: moko-platform.CLI
|
* FILE INFORMATION
|
||||||
* INGROUP: moko-platform
|
* DEFGROUP: moko-platform.CLI
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* INGROUP: moko-platform
|
||||||
* PATH: /cli/client_health_check.php
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* BRIEF: Verify a client site's update server, installed version, and release availability
|
* 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
|
declare(strict_types=1);
|
||||||
*
|
|
||||||
* Options:
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
* --path Repository root (reads update server URL from manifest)
|
|
||||||
* --update-url Update server XML URL (overrides manifest)
|
use MokoEnterprise\CliFramework;
|
||||||
* --site-url Live site URL for version checking via Joomla API (optional)
|
|
||||||
* --api-token Joomla API token for site-url (optional)
|
class ClientHealthCheckCli extends CliFramework
|
||||||
* --github-output Export results to $GITHUB_OUTPUT
|
{
|
||||||
*/
|
protected function configure(): void
|
||||||
|
{
|
||||||
declare(strict_types=1);
|
$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)', '.');
|
||||||
$path = '.';
|
$this->addArgument('--update-url', 'Update server XML URL (overrides manifest)', '');
|
||||||
$updateUrl = null;
|
$this->addArgument('--site-url', 'Live site URL for version checking via Joomla API', '');
|
||||||
$siteUrl = null;
|
$this->addArgument('--api-token', 'Joomla API token for site-url', '');
|
||||||
$apiToken = null;
|
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
||||||
$ghOutput = false;
|
}
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
protected function run(): int
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
{
|
||||||
if ($arg === '--update-url' && isset($argv[$i + 1])) $updateUrl = $argv[$i + 1];
|
$path = $this->getArgument('--path');
|
||||||
if ($arg === '--site-url' && isset($argv[$i + 1])) $siteUrl = $argv[$i + 1];
|
$updateUrl = $this->getArgument('--update-url');
|
||||||
if ($arg === '--api-token' && isset($argv[$i + 1])) $apiToken = $argv[$i + 1];
|
$siteUrl = $this->getArgument('--site-url');
|
||||||
if ($arg === '--github-output') $ghOutput = true;
|
$apiToken = $this->getArgument('--api-token');
|
||||||
}
|
$ghOutput = $this->getArgument('--github-output');
|
||||||
|
|
||||||
$root = realpath($path) ?: $path;
|
$root = realpath($path) ?: $path;
|
||||||
$checks = [];
|
$checks = [];
|
||||||
|
|
||||||
// ── Resolve update server URL from manifest ─────────────────────────────
|
// -- Resolve update server URL from manifest --
|
||||||
if ($updateUrl === null) {
|
if ($updateUrl === '') {
|
||||||
$searchDirs = ["{$root}/src", $root];
|
$updateUrl = null;
|
||||||
foreach ($searchDirs as $dir) {
|
$searchDirs = ["{$root}/src", $root];
|
||||||
if (!is_dir($dir)) continue;
|
foreach ($searchDirs as $dir) {
|
||||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
if (!is_dir($dir)) {
|
||||||
$xml = file_get_contents($f);
|
continue;
|
||||||
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
|
}
|
||||||
$updateUrl = trim($m[1]);
|
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||||
break 2;
|
$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);
|
|
||||||
}
|
if ($updateUrl === null || $updateUrl === '') {
|
||||||
|
$this->log('ERROR', 'No update server URL found. Use --update-url or provide a manifest with <updateservers>.');
|
||||||
echo "Update server: {$updateUrl}\n\n";
|
return 1;
|
||||||
|
}
|
||||||
// ── Check 1: Update server accessible ───────────────────────────────────
|
|
||||||
echo "--- Update Server ---\n";
|
echo "Update server: {$updateUrl}\n\n";
|
||||||
$ch = curl_init($updateUrl);
|
|
||||||
curl_setopt_array($ch, [
|
// -- Check 1: Update server accessible --
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
echo "--- Update Server ---\n";
|
||||||
CURLOPT_TIMEOUT => 15,
|
$ch = curl_init($updateUrl);
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
]);
|
CURLOPT_TIMEOUT => 15,
|
||||||
$response = curl_exec($ch);
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
|
||||||
curl_close($ch);
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
if ($httpCode === 200 && !empty($response)) {
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
|
curl_close($ch);
|
||||||
$checks['update_server'] = 'pass';
|
|
||||||
} else {
|
if ($httpCode === 200 && !empty($response)) {
|
||||||
echo " FAIL: HTTP {$httpCode}\n";
|
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
|
||||||
$checks['update_server'] = 'fail';
|
$checks['update_server'] = 'pass';
|
||||||
}
|
} else {
|
||||||
|
echo " FAIL: HTTP {$httpCode}\n";
|
||||||
// ── Check 2: Parse updates.xml for stable version ───────────────────────
|
$checks['update_server'] = 'fail';
|
||||||
$stableVersion = null;
|
}
|
||||||
$downloadUrl = null;
|
|
||||||
|
// -- Check 2: Parse updates.xml for stable version --
|
||||||
if (!empty($response)) {
|
$stableVersion = null;
|
||||||
$sections = preg_split('/<update>/', $response);
|
$downloadUrl = null;
|
||||||
foreach ($sections as $section) {
|
|
||||||
if (strpos($section, '<tag>stable</tag>') !== false) {
|
if (!empty($response)) {
|
||||||
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
|
$sections = preg_split('/<update>/', $response);
|
||||||
$stableVersion = $m[1];
|
foreach ($sections as $section) {
|
||||||
}
|
if (strpos($section, '<tag>stable</tag>') !== false) {
|
||||||
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
|
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
|
||||||
$downloadUrl = trim($m[1]);
|
$stableVersion = $m[1];
|
||||||
}
|
}
|
||||||
break;
|
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
|
||||||
}
|
$downloadUrl = trim($m[1]);
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
|
}
|
||||||
$stableVersion = $m[1];
|
}
|
||||||
}
|
|
||||||
}
|
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;
|
echo "\n--- Stable Release ---\n";
|
||||||
} else {
|
if ($stableVersion !== null) {
|
||||||
echo " FAIL: Could not parse stable version\n";
|
echo " Version: {$stableVersion}\n";
|
||||||
$checks['stable_version'] = 'fail';
|
$checks['stable_version'] = $stableVersion;
|
||||||
}
|
} else {
|
||||||
|
echo " FAIL: Could not parse stable version\n";
|
||||||
// ── Check 3: Download URL accessible ────────────────────────────────────
|
$checks['stable_version'] = 'fail';
|
||||||
if ($downloadUrl !== null) {
|
}
|
||||||
echo "\n--- Download URL ---\n";
|
|
||||||
$ch = curl_init($downloadUrl);
|
// -- Check 3: Download URL accessible --
|
||||||
curl_setopt_array($ch, [
|
if ($downloadUrl !== null) {
|
||||||
CURLOPT_NOBODY => true,
|
echo "\n--- Download URL ---\n";
|
||||||
CURLOPT_TIMEOUT => 15,
|
$ch = curl_init($downloadUrl);
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
curl_setopt_array($ch, [
|
||||||
]);
|
CURLOPT_NOBODY => true,
|
||||||
curl_exec($ch);
|
CURLOPT_TIMEOUT => 15,
|
||||||
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
|
]);
|
||||||
curl_close($ch);
|
curl_exec($ch);
|
||||||
|
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
if ($dlCode === 200) {
|
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
|
||||||
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
|
curl_close($ch);
|
||||||
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
|
|
||||||
$checks['download'] = 'pass';
|
if ($dlCode === 200) {
|
||||||
} else {
|
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
|
||||||
echo " FAIL: HTTP {$dlCode}\n";
|
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
|
||||||
$checks['download'] = 'fail';
|
$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';
|
// -- Check 4: Site version (optional) --
|
||||||
$ch = curl_init($apiUrl);
|
if ($siteUrl !== '' && $apiToken !== '') {
|
||||||
curl_setopt_array($ch, [
|
echo "\n--- Site Version ---\n";
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
|
||||||
CURLOPT_TIMEOUT => 15,
|
$ch = curl_init($apiUrl);
|
||||||
CURLOPT_HTTPHEADER => [
|
curl_setopt_array($ch, [
|
||||||
"X-Joomla-Token: {$apiToken}",
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
'Accept: application/json',
|
CURLOPT_TIMEOUT => 15,
|
||||||
],
|
CURLOPT_HTTPHEADER => [
|
||||||
]);
|
"X-Joomla-Token: {$apiToken}",
|
||||||
$siteResponse = curl_exec($ch);
|
'Accept: application/json',
|
||||||
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
],
|
||||||
curl_close($ch);
|
]);
|
||||||
|
$siteResponse = curl_exec($ch);
|
||||||
if ($siteCode === 200) {
|
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
echo " API accessible (HTTP {$siteCode})\n";
|
curl_close($ch);
|
||||||
$checks['site_api'] = 'pass';
|
|
||||||
} else {
|
if ($siteCode === 200) {
|
||||||
echo " WARN: Site API returned HTTP {$siteCode}\n";
|
echo " API accessible (HTTP {$siteCode})\n";
|
||||||
$checks['site_api'] = 'warn';
|
$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) {
|
// -- Summary --
|
||||||
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
|
echo "\n=== Health Check Summary ===\n";
|
||||||
if ($result === 'fail') $failed++;
|
$failed = 0;
|
||||||
echo " {$icon}: {$name} = {$result}\n";
|
foreach ($checks as $name => $result) {
|
||||||
}
|
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
|
||||||
|
if ($result === 'fail') {
|
||||||
if ($ghOutput) {
|
$failed++;
|
||||||
$ghFile = getenv('GITHUB_OUTPUT');
|
}
|
||||||
if ($ghFile) {
|
echo " {$icon}: {$name} = {$result}\n";
|
||||||
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);
|
if ($ghOutput) {
|
||||||
}
|
$ghFile = getenv('GITHUB_OUTPUT');
|
||||||
}
|
if ($ghFile) {
|
||||||
|
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND);
|
||||||
exit($failed > 0 ? 1 : 0);
|
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
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -11,324 +12,266 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/client_inventory.php
|
* PATH: /cli/client_inventory.php
|
||||||
* VERSION: 01.00.00
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Discover and list all client-waas repos with their server configuration status
|
* BRIEF: Discover and list all client-waas repos with their server configuration status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
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 $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||||
private string $token = '';
|
private string $token = '';
|
||||||
private bool $jsonOutput = false;
|
private bool $jsonOutput = false;
|
||||||
|
|
||||||
public function run(): int
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->parseArgs();
|
$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 === '')
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$this->log('ERROR: --token is required.');
|
$this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||||
$this->printUsage();
|
$this->token = $this->getArgument('--token');
|
||||||
return 1;
|
$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
|
$this->log('INFO', "Scanning Gitea instance: {$this->giteaUrl}");
|
||||||
$orgs = $this->fetchOrgs();
|
|
||||||
|
|
||||||
if ($orgs === null)
|
// Step 1: List all orgs
|
||||||
{
|
$orgs = $this->fetchOrgs();
|
||||||
$this->log('ERROR: Failed to fetch organizations.');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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
|
$this->log('INFO', 'Found ' . count($orgs) . ' organization(s).');
|
||||||
$inventory = [];
|
|
||||||
|
|
||||||
foreach ($orgs as $org)
|
// Step 2 & 3: For each org, find client-waas repos
|
||||||
{
|
$inventory = [];
|
||||||
$orgName = $org['username'] ?? $org['name'] ?? '';
|
|
||||||
|
|
||||||
if ($orgName === '')
|
foreach ($orgs as $org) {
|
||||||
{
|
$orgName = $org['username'] ?? $org['name'] ?? '';
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$repos = $this->fetchOrgRepos($orgName);
|
if ($orgName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($repos === null)
|
$repos = $this->fetchOrgRepos($orgName);
|
||||||
{
|
|
||||||
$this->log("WARNING: Could not fetch repos for org: {$orgName}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($repos as $repo)
|
if ($repos === null) {
|
||||||
{
|
$this->log('WARNING', "Could not fetch repos for org: {$orgName}");
|
||||||
$repoName = $repo['name'] ?? '';
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (strpos($repoName, 'client-waas') === false)
|
foreach ($repos as $repo) {
|
||||||
{
|
$repoName = $repo['name'] ?? '';
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']);
|
if (strpos($repoName, 'client-waas') === false) {
|
||||||
$hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']);
|
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 = $repo['updated_at'] ?? 'unknown';
|
||||||
{
|
|
||||||
$lastPush = substr($lastPush, 0, 19);
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = 'OK';
|
if ($lastPush !== 'unknown') {
|
||||||
|
$lastPush = substr($lastPush, 0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$hasDevConfig && !$hasLiveConfig)
|
$status = 'OK';
|
||||||
{
|
|
||||||
$status = 'UNCONFIGURED';
|
|
||||||
}
|
|
||||||
elseif (!$hasDevConfig)
|
|
||||||
{
|
|
||||||
$status = 'NO DEV';
|
|
||||||
}
|
|
||||||
elseif (!$hasLiveConfig)
|
|
||||||
{
|
|
||||||
$status = 'NO LIVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
$inventory[] = [
|
if (!$hasDevConfig && !$hasLiveConfig) {
|
||||||
'org' => $orgName,
|
$status = 'UNCONFIGURED';
|
||||||
'repo' => $repoName,
|
} elseif (!$hasDevConfig) {
|
||||||
'has_dev_config' => $hasDevConfig,
|
$status = 'NO DEV';
|
||||||
'has_live_config' => $hasLiveConfig,
|
} elseif (!$hasLiveConfig) {
|
||||||
'last_push' => $lastPush,
|
$status = 'NO LIVE';
|
||||||
'status' => $status,
|
}
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output results
|
$inventory[] = [
|
||||||
if ($this->jsonOutput)
|
'org' => $orgName,
|
||||||
{
|
'repo' => $repoName,
|
||||||
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
'has_dev_config' => $hasDevConfig,
|
||||||
return 0;
|
'has_live_config' => $hasLiveConfig,
|
||||||
}
|
'last_push' => $lastPush,
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (count($inventory) === 0)
|
// Output results
|
||||||
{
|
if ($this->jsonOutput) {
|
||||||
$this->log('No client-waas repos found.');
|
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print table
|
if (count($inventory) === 0) {
|
||||||
$this->log('');
|
$this->log('INFO', 'No client-waas repos found.');
|
||||||
$this->log(sprintf(
|
return 0;
|
||||||
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
}
|
||||||
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status'
|
|
||||||
));
|
|
||||||
$this->log(str_repeat('-', 120));
|
|
||||||
|
|
||||||
foreach ($inventory as $entry)
|
// Print table
|
||||||
{
|
$this->log('INFO', '');
|
||||||
$this->log(sprintf(
|
$this->log('INFO', sprintf(
|
||||||
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
||||||
$entry['org'],
|
'Org',
|
||||||
$entry['repo'],
|
'Repo',
|
||||||
$entry['has_dev_config'] ? 'Yes' : 'No',
|
'Dev Config',
|
||||||
$entry['has_live_config'] ? 'Yes' : 'No',
|
'Live Config',
|
||||||
$entry['last_push'],
|
'Last Push',
|
||||||
$entry['status']
|
'Status'
|
||||||
));
|
));
|
||||||
}
|
$this->log('INFO', str_repeat('-', 120));
|
||||||
|
|
||||||
$this->log('');
|
foreach ($inventory as $entry) {
|
||||||
$this->log('Total: ' . count($inventory) . ' client-waas repo(s).');
|
$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
|
return 0;
|
||||||
{
|
}
|
||||||
$args = $_SERVER['argv'] ?? [];
|
|
||||||
$count = count($args);
|
|
||||||
|
|
||||||
for ($i = 1; $i < $count; $i++)
|
private function fetchOrgs(): ?array
|
||||||
{
|
{
|
||||||
switch ($args[$i])
|
// Try admin endpoint first, fall back to user-visible orgs
|
||||||
{
|
$response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50');
|
||||||
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 printUsage(): void
|
if ($response['code'] >= 200 && $response['code'] < 300) {
|
||||||
{
|
$data = json_decode($response['body'], true);
|
||||||
$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');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fetchOrgs(): ?array
|
if (is_array($data)) {
|
||||||
{
|
return $data;
|
||||||
// Try admin endpoint first, fall back to user-visible orgs
|
}
|
||||||
$response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50');
|
}
|
||||||
|
|
||||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
$this->log('INFO', 'Admin orgs endpoint unavailable, falling back to user orgs...');
|
||||||
{
|
|
||||||
$data = json_decode($response['body'], true);
|
|
||||||
|
|
||||||
if (is_array($data))
|
$response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50');
|
||||||
{
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$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)
|
return null;
|
||||||
{
|
}
|
||||||
$data = json_decode($response['body'], true);
|
|
||||||
|
|
||||||
if (is_array($data))
|
private function fetchOrgRepos(string $org): ?array
|
||||||
{
|
{
|
||||||
return $data;
|
$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
|
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||||
{
|
return $page === 1 ? null : $allRepos;
|
||||||
$page = 1;
|
}
|
||||||
$allRepos = [];
|
|
||||||
|
|
||||||
while (true)
|
$data = json_decode($response['body'], true);
|
||||||
{
|
|
||||||
$response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}");
|
|
||||||
|
|
||||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
if (!is_array($data) || count($data) === 0) {
|
||||||
{
|
break;
|
||||||
return $page === 1 ? null : $allRepos;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($response['body'], true);
|
$allRepos = array_merge($allRepos, $data);
|
||||||
|
$page++;
|
||||||
|
}
|
||||||
|
|
||||||
if (!is_array($data) || count($data) === 0)
|
return $allRepos;
|
||||||
{
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$allRepos = array_merge($allRepos, $data);
|
private function checkVariables(string $org, string $repo, array $requiredVars): bool
|
||||||
$page++;
|
{
|
||||||
}
|
$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
|
$data = json_decode($response['body'], true);
|
||||||
{
|
|
||||||
$response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables");
|
|
||||||
|
|
||||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
if (!is_array($data)) {
|
||||||
{
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($response['body'], true);
|
$existingVars = [];
|
||||||
|
|
||||||
if (!is_array($data))
|
foreach ($data as $variable) {
|
||||||
{
|
if (isset($variable['name'])) {
|
||||||
return false;
|
$existingVars[] = $variable['name'];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$existingVars = [];
|
foreach ($requiredVars as $var) {
|
||||||
|
if (!in_array($var, $existingVars, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($data as $variable)
|
return true;
|
||||||
{
|
}
|
||||||
if (isset($variable['name']))
|
|
||||||
{
|
|
||||||
$existingVars[] = $variable['name'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($requiredVars as $var)
|
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
|
||||||
{
|
{
|
||||||
if (!in_array($var, $existingVars, true))
|
$url = $this->giteaUrl . $endpoint;
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
if ($body !== null) {
|
||||||
{
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
$url = $this->giteaUrl . $endpoint;
|
}
|
||||||
|
|
||||||
$ch = curl_init();
|
$responseBody = curl_exec($ch);
|
||||||
curl_setopt($ch, CURLOPT_URL, $url);
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
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)
|
if (curl_errno($ch)) {
|
||||||
{
|
$error = curl_error($ch);
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
curl_close($ch);
|
||||||
}
|
|
||||||
|
|
||||||
$responseBody = curl_exec($ch);
|
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
}
|
||||||
|
|
||||||
if (curl_errno($ch))
|
curl_close($ch);
|
||||||
{
|
|
||||||
$error = curl_error($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
return ['code' => $httpCode, 'body' => $responseBody];
|
||||||
}
|
}
|
||||||
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
return ['code' => $httpCode, 'body' => $responseBody];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function log(string $message): void
|
|
||||||
{
|
|
||||||
fwrite(STDERR, $message . PHP_EOL);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new ClientInventory();
|
$app = new ClientInventoryCli();
|
||||||
exit($app->run());
|
exit($app->execute());
|
||||||
|
|||||||
+69
-112
@@ -12,13 +12,17 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/client_provision.php
|
* PATH: /cli/client_provision.php
|
||||||
* VERSION: 01.00.00
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Provision a new client environment end-to-end
|
* BRIEF: Provision a new client environment end-to-end
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
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 $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||||
private string $giteaToken = '';
|
private string $giteaToken = '';
|
||||||
@@ -26,24 +30,30 @@ final class ClientProvision
|
|||||||
private string $grafanaToken = '';
|
private string $grafanaToken = '';
|
||||||
private string $configFile = '';
|
private string $configFile = '';
|
||||||
private string $step = '';
|
private string $step = '';
|
||||||
private bool $dryRun = false;
|
|
||||||
/** @var array<string, mixed> */
|
/** @var array<string, mixed> */
|
||||||
private array $config = [];
|
private array $config = [];
|
||||||
private string $org = '';
|
private string $org = '';
|
||||||
private string $repoName = '';
|
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 === '') {
|
if ($this->configFile === '') {
|
||||||
$this->log('ERROR: --config is required.');
|
$this->log('ERROR', '--config is required.');
|
||||||
$this->printUsage();
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file_exists($this->configFile)) {
|
if (!file_exists($this->configFile)) {
|
||||||
$this->log("ERROR: Not found: {$this->configFile}");
|
$this->log('ERROR', "Not found: {$this->configFile}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,12 +61,12 @@ final class ClientProvision
|
|||||||
$this->config = json_decode($json, true);
|
$this->config = json_decode($json, true);
|
||||||
|
|
||||||
if (!is_array($this->config)) {
|
if (!is_array($this->config)) {
|
||||||
$this->log('ERROR: Invalid JSON in config file.');
|
$this->log('ERROR', 'Invalid JSON in config file.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->giteaToken = $this->config['gitea_token']
|
$this->giteaToken = $this->config['gitea_token']
|
||||||
?? getenv('GA_TOKEN') ?: '';
|
?? getenv('MOKOGITEA_TOKEN') ?: '';
|
||||||
$this->grafanaUrl = $this->config['grafana_url']
|
$this->grafanaUrl = $this->config['grafana_url']
|
||||||
?? getenv('GRAFANA_URL') ?: '';
|
?? getenv('GRAFANA_URL') ?: '';
|
||||||
$this->grafanaToken = $this->config['grafana_token']
|
$this->grafanaToken = $this->config['grafana_token']
|
||||||
@@ -65,7 +75,7 @@ final class ClientProvision
|
|||||||
?? $this->giteaUrl;
|
?? $this->giteaUrl;
|
||||||
|
|
||||||
if ($this->giteaToken === '') {
|
if ($this->giteaToken === '') {
|
||||||
$this->log('ERROR: gitea_token or GA_TOKEN required.');
|
$this->log('ERROR', 'gitea_token or MOKOGITEA_TOKEN required.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,21 +83,21 @@ final class ClientProvision
|
|||||||
$clientName = $this->config['name'] ?? '';
|
$clientName = $this->config['name'] ?? '';
|
||||||
|
|
||||||
if ($this->org === '' || $clientName === '') {
|
if ($this->org === '' || $clientName === '') {
|
||||||
$this->log('ERROR: "org" and "name" required in config.');
|
$this->log('ERROR', '"org" and "name" required in config.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->repoName = 'client-waas-' . $clientName;
|
$this->repoName = 'client-waas-' . $clientName;
|
||||||
|
|
||||||
$this->log("=== Client Provisioning: {$clientName} ===");
|
$this->log('INFO', "=== Client Provisioning: {$clientName} ===");
|
||||||
$this->log(" Org: {$this->org}");
|
$this->log('INFO', " Org: {$this->org}");
|
||||||
$this->log(" Repo: {$this->repoName}");
|
$this->log('INFO', " Repo: {$this->repoName}");
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log(' Mode: DRY RUN');
|
$this->log('INFO', ' Mode: DRY RUN');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('');
|
echo "\n";
|
||||||
|
|
||||||
$steps = [
|
$steps = [
|
||||||
'repo' => 'createRepo',
|
'repo' => 'createRepo',
|
||||||
@@ -116,7 +126,7 @@ final class ClientProvision
|
|||||||
|
|
||||||
private function createRepo(): int
|
private function createRepo(): int
|
||||||
{
|
{
|
||||||
$this->log('[1/5] Creating repository...');
|
$this->log('INFO', '[1/5] Creating repository...');
|
||||||
|
|
||||||
$check = $this->giteaApi(
|
$check = $this->giteaApi(
|
||||||
'GET',
|
'GET',
|
||||||
@@ -124,14 +134,12 @@ final class ClientProvision
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($check['code'] === 200) {
|
if ($check['code'] === 200) {
|
||||||
$this->log(" SKIP: repo already exists");
|
$this->log('INFO', ' SKIP: repo already exists');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log(
|
$this->log('INFO', " WOULD CREATE: {$this->org}/{$this->repoName}");
|
||||||
" WOULD CREATE: {$this->org}/{$this->repoName}"
|
|
||||||
);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,11 +161,11 @@ final class ClientProvision
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($resp['code'] < 200 || $resp['code'] >= 300) {
|
if ($resp['code'] < 200 || $resp['code'] >= 300) {
|
||||||
$this->log(" ERROR: HTTP {$resp['code']}");
|
$this->log('ERROR', "HTTP {$resp['code']}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log(' OK: Repo created');
|
$this->log('INFO', ' OK: Repo created');
|
||||||
|
|
||||||
$this->giteaApi(
|
$this->giteaApi(
|
||||||
'POST',
|
'POST',
|
||||||
@@ -168,19 +176,19 @@ final class ClientProvision
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->log(' OK: dev branch created');
|
$this->log('INFO', ' OK: dev branch created');
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function setVariables(): int
|
private function setVariables(): int
|
||||||
{
|
{
|
||||||
$this->log('[2/5] Setting repo variables...');
|
$this->log('INFO', '[2/5] Setting repo variables...');
|
||||||
|
|
||||||
$vars = $this->config['variables'] ?? [];
|
$vars = $this->config['variables'] ?? [];
|
||||||
|
|
||||||
if (empty($vars)) {
|
if (empty($vars)) {
|
||||||
$this->log(' SKIP: No variables in config');
|
$this->log('INFO', ' SKIP: No variables in config');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,16 +200,16 @@ final class ClientProvision
|
|||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$display = strlen($value) > 40
|
$display = strlen($value) > 40
|
||||||
? substr($value, 0, 37) . '...' : $value;
|
? substr($value, 0, 37) . '...' : $value;
|
||||||
$this->log(" WOULD SET: {$name} = {$display}");
|
$this->log('INFO', " WOULD SET: {$name} = {$display}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ok = $this->setOrCreateVariable($api, $name, $value);
|
$ok = $this->setOrCreateVariable($api, $name, $value);
|
||||||
|
|
||||||
if ($ok) {
|
if ($ok) {
|
||||||
$this->log(" OK: {$name}");
|
$this->log('INFO', " OK: {$name}");
|
||||||
} else {
|
} else {
|
||||||
$this->log(" ERROR: {$name}");
|
$this->log('ERROR', " {$name}");
|
||||||
$errors++;
|
$errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,12 +219,12 @@ final class ClientProvision
|
|||||||
|
|
||||||
private function setSecrets(): int
|
private function setSecrets(): int
|
||||||
{
|
{
|
||||||
$this->log('[3/5] Setting repo secrets...');
|
$this->log('INFO', '[3/5] Setting repo secrets...');
|
||||||
|
|
||||||
$secrets = $this->config['secrets'] ?? [];
|
$secrets = $this->config['secrets'] ?? [];
|
||||||
|
|
||||||
if (empty($secrets)) {
|
if (empty($secrets)) {
|
||||||
$this->log(' SKIP: No secrets in config');
|
$this->log('INFO', ' SKIP: No secrets in config');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +237,7 @@ final class ClientProvision
|
|||||||
$keyPath = substr($value, 1);
|
$keyPath = substr($value, 1);
|
||||||
|
|
||||||
if (!file_exists($keyPath)) {
|
if (!file_exists($keyPath)) {
|
||||||
$this->log(" ERROR: {$name} file not found: {$keyPath}");
|
$this->log('ERROR', " {$name} file not found: {$keyPath}");
|
||||||
$errors++;
|
$errors++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -238,7 +246,7 @@ final class ClientProvision
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")");
|
$this->log('INFO', " WOULD SET: {$name} (len: " . strlen($value) . ")");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +257,9 @@ final class ClientProvision
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($resp['code'] >= 200 && $resp['code'] < 300) {
|
if ($resp['code'] >= 200 && $resp['code'] < 300) {
|
||||||
$this->log(" OK: {$name}");
|
$this->log('INFO', " OK: {$name}");
|
||||||
} else {
|
} else {
|
||||||
$this->log(" ERROR: {$name} (HTTP {$resp['code']})");
|
$this->log('ERROR', " {$name} (HTTP {$resp['code']})");
|
||||||
$errors++;
|
$errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,12 +269,12 @@ final class ClientProvision
|
|||||||
|
|
||||||
private function setupMonitoring(): int
|
private function setupMonitoring(): int
|
||||||
{
|
{
|
||||||
$this->log('[4/5] Setting up monitoring...');
|
$this->log('INFO', '[4/5] Setting up monitoring...');
|
||||||
|
|
||||||
$mon = $this->config['monitoring'] ?? [];
|
$mon = $this->config['monitoring'] ?? [];
|
||||||
|
|
||||||
if (empty($mon)) {
|
if (empty($mon)) {
|
||||||
$this->log(' SKIP: No monitoring config');
|
$this->log('INFO', ' SKIP: No monitoring config');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,10 +299,10 @@ final class ClientProvision
|
|||||||
$urlStr = implode("\n", $urls);
|
$urlStr = implode("\n", $urls);
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log(" WOULD SET: MONITORED_URLS");
|
$this->log('INFO', ' WOULD SET: MONITORED_URLS');
|
||||||
} else {
|
} else {
|
||||||
$this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr);
|
$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);
|
$domainStr = implode("\n", $domains);
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log(" WOULD SET: MONITORED_DOMAINS");
|
$this->log('INFO', ' WOULD SET: MONITORED_DOMAINS');
|
||||||
} else {
|
} else {
|
||||||
$this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr);
|
$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
|
private function pushGrafanaDashboard(string $file, string $folder): void
|
||||||
{
|
{
|
||||||
if (!file_exists($file)) {
|
if (!file_exists($file)) {
|
||||||
$this->log(" WARN: Dashboard not found: {$file}");
|
$this->warning("Dashboard not found: {$file}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->dryRun) {
|
if ($this->dryRun) {
|
||||||
$this->log(" WOULD PUSH: dashboard to \"{$folder}\"");
|
$this->log('INFO', " WOULD PUSH: dashboard to \"{$folder}\"");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dashboard = json_decode(file_get_contents($file), true);
|
$dashboard = json_decode(file_get_contents($file), true);
|
||||||
|
|
||||||
if (!is_array($dashboard)) {
|
if (!is_array($dashboard)) {
|
||||||
$this->log(' ERROR: Invalid dashboard JSON');
|
$this->log('ERROR', 'Invalid dashboard JSON');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,9 +354,9 @@ final class ClientProvision
|
|||||||
|
|
||||||
if ($resp['code'] === 200) {
|
if ($resp['code'] === 200) {
|
||||||
$data = json_decode($resp['body'], true);
|
$data = json_decode($resp['body'], true);
|
||||||
$this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
|
$this->log('INFO', " OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
|
||||||
} else {
|
} 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'] ?? [];
|
$vars = $this->config['variables'] ?? [];
|
||||||
$secrets = $this->config['secrets'] ?? [];
|
$secrets = $this->config['secrets'] ?? [];
|
||||||
$clientName = $this->config['name'] ?? '';
|
|
||||||
|
|
||||||
$this->log('');
|
echo "\n";
|
||||||
$this->log('[5/5] Provisioning summary');
|
$this->log('INFO', '[5/5] Provisioning summary');
|
||||||
$this->log(str_repeat('=', 60));
|
echo str_repeat('=', 60) . "\n";
|
||||||
$this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}");
|
echo " Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}\n";
|
||||||
$this->log(' Variables: ' . count($vars) . ' set');
|
echo ' Variables: ' . count($vars) . " set\n";
|
||||||
$this->log(' Secrets: ' . count($secrets) . ' set');
|
echo ' Secrets: ' . count($secrets) . " set\n";
|
||||||
$this->log('');
|
echo "\n";
|
||||||
$this->log('Next steps:');
|
echo "Next steps:\n";
|
||||||
$this->log(' 1. Clone and customize the Joomla template');
|
echo " 1. Clone and customize the Joomla template\n";
|
||||||
$this->log(' 2. Push to dev to trigger dev deployment');
|
echo " 2. Push to dev to trigger dev deployment\n";
|
||||||
$this->log(' 3. Merge dev -> main for production release');
|
echo " 3. Merge dev -> main for production release\n";
|
||||||
$this->log(str_repeat('=', 60));
|
echo str_repeat('=', 60) . "\n";
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -419,51 +426,6 @@ final class ClientProvision
|
|||||||
return $resp['code'] >= 200 && $resp['code'] < 300;
|
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(
|
private function giteaApi(
|
||||||
string $method,
|
string $method,
|
||||||
string $endpoint,
|
string $endpoint,
|
||||||
@@ -523,12 +485,7 @@ final class ClientProvision
|
|||||||
|
|
||||||
return ['code' => $httpCode, 'body' => $responseBody];
|
return ['code' => $httpCode, 'body' => $responseBody];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
|
||||||
{
|
|
||||||
fwrite(STDERR, $message . PHP_EOL);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new ClientProvision();
|
$app = new ClientProvisionCli();
|
||||||
exit($app->run());
|
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
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -12,469 +13,431 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/create_project.php
|
* PATH: /cli/create_project.php
|
||||||
* BRIEF: Create baseline GitHub Projects for repositories with standard fields and views
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$dryRun = in_array('--dry-run', $argv);
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$allMode = in_array('--all', $argv);
|
|
||||||
|
|
||||||
$org = 'mokoconsulting-tech';
|
use MokoEnterprise\CliFramework;
|
||||||
$repoName = null;
|
|
||||||
$typeOverride = null;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
class CreateProjectCli extends CliFramework
|
||||||
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
|
|
||||||
{
|
{
|
||||||
if ($platformName !== 'github') {
|
/** @var string[] */
|
||||||
return [];
|
private array $ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
|
||||||
}
|
|
||||||
$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);
|
|
||||||
|
|
||||||
if ($status !== 200) {
|
/** @var array<string, string> */
|
||||||
fwrite(STDERR, "GraphQL request failed (HTTP {$status}): {$body}\n");
|
private array $PLATFORM_TO_TYPE = [
|
||||||
return [];
|
'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) ?? [];
|
/** @var array<string, string> */
|
||||||
if (!empty($data['errors'])) {
|
private array $TYPE_TO_TEMPLATE = [
|
||||||
foreach ($data['errors'] as $err) {
|
'generic' => 'generic-project-definition.tf',
|
||||||
fwrite(STDERR, " GraphQL error: " . ($err['message'] ?? 'unknown') . "\n");
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
$app = new CreateProjectCli();
|
||||||
* Execute a REST API GET call via the platform adapter's ApiClient.
|
exit($app->execute());
|
||||||
*
|
|
||||||
* @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);
|
|
||||||
|
|||||||
+201
-227
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -11,243 +12,216 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/create_repo.php
|
* PATH: /cli/create_repo.php
|
||||||
* BRIEF: Scaffold a new governed repository with full MokoStandards baseline
|
* BRIEF: Scaffold a new governed repository with full moko-platform 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
|
|
||||||
|
use MokoEnterprise\CliFramework;
|
||||||
use MokoEnterprise\Config;
|
use MokoEnterprise\Config;
|
||||||
use MokoEnterprise\PlatformAdapterFactory;
|
use MokoEnterprise\PlatformAdapterFactory;
|
||||||
|
|
||||||
$dryRun = in_array('--dry-run', $argv);
|
class CreateRepoCli extends CliFramework
|
||||||
$private = in_array('--private', $argv);
|
{
|
||||||
|
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;
|
protected function run(): int
|
||||||
$type = null;
|
{
|
||||||
$description = '';
|
$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) {
|
echo "Step 1: Creating repository...\n";
|
||||||
if ($arg === '--name' && isset($argv[$i + 1])) { $name = $argv[$i + 1]; }
|
if (!$this->dryRun) {
|
||||||
if ($arg === '--type' && isset($argv[$i + 1])) { $type = $argv[$i + 1]; }
|
try {
|
||||||
if ($arg === '--description' && isset($argv[$i + 1])) { $description = $argv[$i + 1]; }
|
$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) {
|
$app = new CreateRepoCli();
|
||||||
fwrite(STDERR, "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]\n");
|
exit($app->execute());
|
||||||
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";
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+81
-78
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -10,88 +11,90 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/dev_branch_reset.php
|
* PATH: /cli/dev_branch_reset.php
|
||||||
* BRIEF: Delete and recreate dev branch from main via Gitea API
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$token = null;
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$apiBase = null;
|
|
||||||
$branch = 'dev';
|
|
||||||
$from = 'main';
|
|
||||||
$outputSummary = false;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
|
||||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
class DevBranchResetCli extends CliFramework
|
||||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
{
|
||||||
if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1];
|
protected function configure(): void
|
||||||
if ($arg === '--output-summary') $outputSummary = true;
|
{
|
||||||
|
$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;
|
$app = new DevBranchResetCli();
|
||||||
|
exit($app->execute());
|
||||||
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);
|
|
||||||
|
|||||||
+78
-175
@@ -12,13 +12,17 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/grafana_dashboard.php
|
* PATH: /cli/grafana_dashboard.php
|
||||||
* VERSION: 01.00.00
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Manage Grafana dashboards via API
|
* BRIEF: Manage Grafana dashboards via API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
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 $grafanaUrl = '';
|
||||||
private string $token = '';
|
private string $token = '';
|
||||||
@@ -29,24 +33,52 @@ final class GrafanaDashboard
|
|||||||
private string $folderTitle = '';
|
private string $folderTitle = '';
|
||||||
private bool $overwrite = true;
|
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 === '') {
|
if ($this->grafanaUrl === '') {
|
||||||
$this->grafanaUrl = getenv('GRAFANA_URL') ?: '';
|
$this->grafanaUrl = getenv('GRAFANA_URL') ?: '';
|
||||||
}
|
}
|
||||||
|
$this->grafanaUrl = rtrim($this->grafanaUrl, '/');
|
||||||
|
|
||||||
if ($this->token === '') {
|
if ($this->token === '') {
|
||||||
$this->token = getenv('GRAFANA_TOKEN') ?: '';
|
$this->token = getenv('GRAFANA_TOKEN') ?: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->grafanaUrl === '' || $this->token === '') {
|
if ($this->grafanaUrl === '' || $this->token === '') {
|
||||||
$this->log(
|
$this->log('ERROR', '--url and --token are required (or set GRAFANA_URL / GRAFANA_TOKEN env vars).');
|
||||||
'ERROR: --url and --token are required '
|
|
||||||
. '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).'
|
|
||||||
);
|
|
||||||
$this->printUsage();
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,12 +94,12 @@ final class GrafanaDashboard
|
|||||||
private function pushDashboard(): int
|
private function pushDashboard(): int
|
||||||
{
|
{
|
||||||
if ($this->file === '') {
|
if ($this->file === '') {
|
||||||
$this->log('ERROR: --file is required for push.');
|
$this->log('ERROR', '--file is required for push.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file_exists($this->file)) {
|
if (!file_exists($this->file)) {
|
||||||
$this->log("ERROR: File not found: {$this->file}");
|
$this->log('ERROR', "File not found: {$this->file}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,14 +107,12 @@ final class GrafanaDashboard
|
|||||||
$dashboard = json_decode($json, true);
|
$dashboard = json_decode($json, true);
|
||||||
|
|
||||||
if (!is_array($dashboard)) {
|
if (!is_array($dashboard)) {
|
||||||
$this->log('ERROR: Invalid JSON in dashboard file.');
|
$this->log('ERROR', 'Invalid JSON in dashboard file.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->folderTitle !== '' && $this->folderId === 0) {
|
if ($this->folderTitle !== '' && $this->folderId === 0) {
|
||||||
$this->folderId = $this->resolveFolderId(
|
$this->folderId = $this->resolveFolderId($this->folderTitle);
|
||||||
$this->folderTitle
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($this->folderId < 0) {
|
if ($this->folderId < 0) {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -97,29 +127,23 @@ final class GrafanaDashboard
|
|||||||
'overwrite' => $this->overwrite,
|
'overwrite' => $this->overwrite,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->apiRequest(
|
$response = $this->apiRequest('POST', '/api/dashboards/db', $payload);
|
||||||
'POST',
|
|
||||||
'/api/dashboards/db',
|
|
||||||
$payload
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($response['code'] === 200) {
|
if ($response['code'] === 200) {
|
||||||
$data = json_decode($response['body'], true);
|
$data = json_decode($response['body'], true);
|
||||||
$uid = $data['uid'] ?? '?';
|
$uid = $data['uid'] ?? '?';
|
||||||
$url = $data['url'] ?? '';
|
$url = $data['url'] ?? '';
|
||||||
$status = $data['status'] ?? 'success';
|
$status = $data['status'] ?? 'success';
|
||||||
$this->log("OK: {$status} (uid: {$uid})");
|
$this->log('INFO', "OK: {$status} (uid: {$uid})");
|
||||||
|
|
||||||
if ($url !== '') {
|
if ($url !== '') {
|
||||||
$this->log("URL: {$this->grafanaUrl}{$url}");
|
$this->log('INFO', "URL: {$this->grafanaUrl}{$url}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log(
|
$this->log('ERROR', "Push failed (HTTP {$response['code']})");
|
||||||
"ERROR: Push failed (HTTP {$response['code']})"
|
|
||||||
);
|
|
||||||
$this->logApiError($response['body']);
|
$this->logApiError($response['body']);
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
@@ -128,30 +152,23 @@ final class GrafanaDashboard
|
|||||||
private function deleteDashboard(): int
|
private function deleteDashboard(): int
|
||||||
{
|
{
|
||||||
if ($this->uid === '') {
|
if ($this->uid === '') {
|
||||||
$this->log('ERROR: --uid is required for delete.');
|
$this->log('ERROR', '--uid is required for delete.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->apiRequest(
|
$response = $this->apiRequest('DELETE', "/api/dashboards/uid/{$this->uid}");
|
||||||
'DELETE',
|
|
||||||
"/api/dashboards/uid/{$this->uid}"
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($response['code'] === 200) {
|
if ($response['code'] === 200) {
|
||||||
$this->log("OK: Deleted dashboard {$this->uid}");
|
$this->log('INFO', "OK: Deleted dashboard {$this->uid}");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($response['code'] === 404) {
|
if ($response['code'] === 404) {
|
||||||
$this->log(
|
$this->warning("Dashboard {$this->uid} not found.");
|
||||||
"WARN: Dashboard {$this->uid} not found."
|
|
||||||
);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log(
|
$this->log('ERROR', "Delete failed (HTTP {$response['code']})");
|
||||||
"ERROR: Delete failed (HTTP {$response['code']})"
|
|
||||||
);
|
|
||||||
$this->logApiError($response['body']);
|
$this->logApiError($response['body']);
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
@@ -176,42 +193,33 @@ final class GrafanaDashboard
|
|||||||
$response = $this->apiRequest('GET', $query);
|
$response = $this->apiRequest('GET', $query);
|
||||||
|
|
||||||
if ($response['code'] !== 200) {
|
if ($response['code'] !== 200) {
|
||||||
$this->log(
|
$this->log('ERROR', "List failed (HTTP {$response['code']})");
|
||||||
"ERROR: List failed (HTTP {$response['code']})"
|
|
||||||
);
|
|
||||||
$this->logApiError($response['body']);
|
$this->logApiError($response['body']);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dashboards = json_decode($response['body'], true);
|
$dashboards = json_decode($response['body'], true);
|
||||||
|
|
||||||
if (
|
if (!is_array($dashboards) || count($dashboards) === 0) {
|
||||||
!is_array($dashboards)
|
$this->log('INFO', 'No dashboards found.');
|
||||||
|| count($dashboards) === 0
|
|
||||||
) {
|
|
||||||
$this->log('No dashboards found.');
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log(sprintf(
|
fprintf(STDERR, "%-30s | %-20s | %s\n", 'Title', 'UID', 'Folder');
|
||||||
'%-30s | %-20s | %s',
|
fprintf(STDERR, "%s\n", str_repeat('-', 75));
|
||||||
'Title',
|
|
||||||
'UID',
|
|
||||||
'Folder'
|
|
||||||
));
|
|
||||||
$this->log(str_repeat('-', 75));
|
|
||||||
|
|
||||||
foreach ($dashboards as $d) {
|
foreach ($dashboards as $d) {
|
||||||
$this->log(sprintf(
|
fprintf(
|
||||||
'%-30s | %-20s | %s',
|
STDERR,
|
||||||
|
"%-30s | %-20s | %s\n",
|
||||||
substr($d['title'] ?? '', 0, 30),
|
substr($d['title'] ?? '', 0, 30),
|
||||||
$d['uid'] ?? '',
|
$d['uid'] ?? '',
|
||||||
$d['folderTitle'] ?? 'General'
|
$d['folderTitle'] ?? 'General'
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log('');
|
echo "\n";
|
||||||
$this->log(count($dashboards) . ' dashboard(s).');
|
$this->log('INFO', count($dashboards) . ' dashboard(s).');
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -219,20 +227,14 @@ final class GrafanaDashboard
|
|||||||
private function exportDashboard(): int
|
private function exportDashboard(): int
|
||||||
{
|
{
|
||||||
if ($this->uid === '') {
|
if ($this->uid === '') {
|
||||||
$this->log('ERROR: --uid is required for export.');
|
$this->log('ERROR', '--uid is required for export.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->apiRequest(
|
$response = $this->apiRequest('GET', "/api/dashboards/uid/{$this->uid}");
|
||||||
'GET',
|
|
||||||
"/api/dashboards/uid/{$this->uid}"
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($response['code'] !== 200) {
|
if ($response['code'] !== 200) {
|
||||||
$this->log(
|
$this->log('ERROR', "Export failed (HTTP {$response['code']})");
|
||||||
"ERROR: Export failed "
|
|
||||||
. "(HTTP {$response['code']})"
|
|
||||||
);
|
|
||||||
$this->logApiError($response['body']);
|
$this->logApiError($response['body']);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -241,9 +243,7 @@ final class GrafanaDashboard
|
|||||||
$dashboard = $data['dashboard'] ?? null;
|
$dashboard = $data['dashboard'] ?? null;
|
||||||
|
|
||||||
if ($dashboard === null) {
|
if ($dashboard === null) {
|
||||||
$this->log(
|
$this->log('ERROR', 'No dashboard data in response.');
|
||||||
'ERROR: No dashboard data in response.'
|
|
||||||
);
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,9 +254,7 @@ final class GrafanaDashboard
|
|||||||
|
|
||||||
if ($this->file !== '') {
|
if ($this->file !== '') {
|
||||||
file_put_contents($this->file, $output);
|
file_put_contents($this->file, $output);
|
||||||
$this->log(
|
$this->log('INFO', "Exported {$this->uid} to {$this->file}");
|
||||||
"Exported {$this->uid} to {$this->file}"
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
fwrite(STDOUT, $output);
|
fwrite(STDOUT, $output);
|
||||||
}
|
}
|
||||||
@@ -269,10 +267,7 @@ final class GrafanaDashboard
|
|||||||
$response = $this->apiRequest('GET', '/api/folders');
|
$response = $this->apiRequest('GET', '/api/folders');
|
||||||
|
|
||||||
if ($response['code'] !== 200) {
|
if ($response['code'] !== 200) {
|
||||||
$this->log(
|
$this->log('ERROR', "Could not fetch folders (HTTP {$response['code']})");
|
||||||
"ERROR: Could not fetch folders "
|
|
||||||
. "(HTTP {$response['code']})"
|
|
||||||
);
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,106 +278,22 @@ final class GrafanaDashboard
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($folders as $f) {
|
foreach ($folders as $f) {
|
||||||
if (
|
if (strcasecmp($f['title'] ?? '', $title) === 0) {
|
||||||
strcasecmp(
|
|
||||||
$f['title'] ?? '',
|
|
||||||
$title
|
|
||||||
) === 0
|
|
||||||
) {
|
|
||||||
return (int) ($f['id'] ?? 0);
|
return (int) ($f['id'] ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log(
|
$this->warning("Folder \"{$title}\" not found, using General.");
|
||||||
"WARN: Folder \"{$title}\" not found, "
|
|
||||||
. "using General."
|
|
||||||
);
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function noCommand(): int
|
private function noCommand(): int
|
||||||
{
|
{
|
||||||
$this->log('ERROR: No command specified.');
|
$this->log('ERROR', 'No command specified. Use: push, delete, list, export');
|
||||||
$this->printUsage();
|
|
||||||
return 1;
|
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(
|
private function apiRequest(
|
||||||
string $method,
|
string $method,
|
||||||
string $endpoint,
|
string $endpoint,
|
||||||
@@ -405,10 +316,7 @@ final class GrafanaDashboard
|
|||||||
}
|
}
|
||||||
|
|
||||||
$responseBody = curl_exec($ch);
|
$responseBody = curl_exec($ch);
|
||||||
$httpCode = (int) curl_getinfo(
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$ch,
|
|
||||||
CURLINFO_HTTP_CODE
|
|
||||||
);
|
|
||||||
|
|
||||||
if (curl_errno($ch)) {
|
if (curl_errno($ch)) {
|
||||||
$error = curl_error($ch);
|
$error = curl_error($ch);
|
||||||
@@ -430,15 +338,10 @@ final class GrafanaDashboard
|
|||||||
$data = json_decode($body, true);
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
if (is_array($data) && isset($data['message'])) {
|
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();
|
$app = new GrafanaDashboardCli();
|
||||||
exit($app->run());
|
exit($app->execute());
|
||||||
|
|||||||
+332
-285
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,299 +10,345 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/joomla_build.php
|
* PATH: /cli/joomla_build.php
|
||||||
* VERSION: 05.00.01
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
|
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
|
||||||
* NOTE: Called by pre-release and auto-release workflows.
|
* NOTE: Called by pre-release and auto-release workflows.
|
||||||
*
|
|
||||||
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
// ── Argument parsing ────────────────────────────────────────────────────
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$path = '.';
|
|
||||||
$version = '';
|
|
||||||
$suffix = '';
|
|
||||||
$outputDir = 'build';
|
|
||||||
$ghOutput = false;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($version === '') {
|
class JoomlaBuildCli extends CliFramework
|
||||||
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
|
|
||||||
{
|
{
|
||||||
// Priority: pkg_*.xml (packages), then any *.xml with <extension>
|
protected function configure(): void
|
||||||
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; }
|
{
|
||||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
$this->setDescription('Build a Joomla extension ZIP from manifest');
|
||||||
if (str_contains((string) file_get_contents($f), '<extension')) { return $f; }
|
$this->addArgument('--path', 'Repository root path', '.');
|
||||||
}
|
$this->addArgument('--version', 'Version string (required)', '');
|
||||||
// Broader nested search
|
$this->addArgument('--suffix', 'Version suffix (e.g. -dev)', '');
|
||||||
$iter = new RecursiveIteratorIterator(
|
$this->addArgument('--output', 'Output directory', 'build');
|
||||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
$this->addArgument('--github-output', 'Write outputs to GITHUB_OUTPUT file', false);
|
||||||
RecursiveIteratorIterator::SELF_FIRST
|
}
|
||||||
);
|
|
||||||
foreach ($iter as $item) {
|
protected function run(): int
|
||||||
if ($item->isFile() && $item->getExtension() === 'xml') {
|
{
|
||||||
if (str_contains((string) file_get_contents($item->getPathname()), '<extension')) {
|
$path = $this->getArgument('--path');
|
||||||
return $item->getPathname();
|
$version = $this->getArgument('--version');
|
||||||
}
|
$suffix = $this->getArgument('--suffix');
|
||||||
}
|
$outputDir = $this->getArgument('--output');
|
||||||
}
|
$ghOutput = (bool) $this->getArgument('--github-output');
|
||||||
return null;
|
|
||||||
|
if ($version === '') {
|
||||||
|
$this->log('ERROR', '::error::--version is required');
|
||||||
|
return 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) {
|
||||||
|
$this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
$app = new JoomlaBuildCli();
|
||||||
{
|
exit($app->execute());
|
||||||
$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);
|
|
||||||
}
|
|
||||||
|
|||||||
+144
-136
@@ -1,136 +1,144 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
*
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
*
|
||||||
*
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
* FILE INFORMATION
|
*
|
||||||
* DEFGROUP: moko-platform.CLI
|
* FILE INFORMATION
|
||||||
* INGROUP: moko-platform
|
* DEFGROUP: moko-platform.CLI
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* INGROUP: moko-platform
|
||||||
* PATH: /cli/joomla_compat_check.php
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version
|
* 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
|
declare(strict_types=1);
|
||||||
*
|
|
||||||
* Options:
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
* --path Repository root (default: .)
|
|
||||||
* --github-output Export results to $GITHUB_OUTPUT
|
use MokoEnterprise\CliFramework;
|
||||||
*/
|
|
||||||
|
class JoomlaCompatCheckCli extends CliFramework
|
||||||
declare(strict_types=1);
|
{
|
||||||
|
protected function configure(): void
|
||||||
$path = '.';
|
{
|
||||||
$ghOutput = false;
|
$this->setDescription('Check if extension targetplatform regex matches the latest Joomla version');
|
||||||
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
foreach ($argv as $i => $arg) {
|
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
}
|
||||||
if ($arg === '--github-output') $ghOutput = true;
|
|
||||||
}
|
protected function run(): int
|
||||||
|
{
|
||||||
$root = realpath($path) ?: $path;
|
$path = $this->getArgument('--path');
|
||||||
|
$ghOutput = $this->getArgument('--github-output');
|
||||||
// ── Find manifest and extract targetplatform ────────────────────────────
|
|
||||||
$manifest = null;
|
$root = realpath($path) ?: $path;
|
||||||
$searchDirs = ["{$root}/src", $root];
|
|
||||||
foreach ($searchDirs as $dir) {
|
// -- Find manifest and extract targetplatform --
|
||||||
if (!is_dir($dir)) continue;
|
$manifest = null;
|
||||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
$searchDirs = ["{$root}/src", $root];
|
||||||
$xml = file_get_contents($f);
|
foreach ($searchDirs as $dir) {
|
||||||
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
|
if (!is_dir($dir)) {
|
||||||
$manifest = $f;
|
continue;
|
||||||
break 2;
|
}
|
||||||
}
|
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||||
}
|
$xml = file_get_contents($f);
|
||||||
}
|
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
|
||||||
|
$manifest = $f;
|
||||||
if ($manifest === null) {
|
break 2;
|
||||||
fwrite(STDERR, "No manifest with targetplatform found\n");
|
}
|
||||||
exit(1);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$xml = file_get_contents($manifest);
|
if ($manifest === null) {
|
||||||
$relManifest = str_replace($root . '/', '', $manifest);
|
$this->log('ERROR', 'No manifest with targetplatform found');
|
||||||
|
return 1;
|
||||||
// Extract targetplatform version regex
|
}
|
||||||
$targetRegex = '';
|
|
||||||
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
|
$xml = file_get_contents($manifest);
|
||||||
$targetRegex = $m[1];
|
$relManifest = str_replace($root . '/', '', $manifest);
|
||||||
}
|
|
||||||
|
// Extract targetplatform version regex
|
||||||
if (empty($targetRegex)) {
|
$targetRegex = '';
|
||||||
echo "No targetplatform version found in {$relManifest}\n";
|
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
|
||||||
exit(1);
|
$targetRegex = $m[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Manifest: {$relManifest}\n";
|
if (empty($targetRegex)) {
|
||||||
echo "Target regex: {$targetRegex}\n";
|
echo "No targetplatform version found in {$relManifest}\n";
|
||||||
|
return 1;
|
||||||
// ── Fetch latest Joomla version ─────────────────────────────────────────
|
}
|
||||||
$joomlaVersions = [];
|
|
||||||
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
|
echo "Manifest: {$relManifest}\n";
|
||||||
$updateXml = @file_get_contents($updateUrl);
|
echo "Target regex: {$targetRegex}\n";
|
||||||
|
|
||||||
if ($updateXml === false) {
|
// -- Fetch latest Joomla version --
|
||||||
// Fallback: try the LTS feed
|
$joomlaVersions = [];
|
||||||
$updateUrl = 'https://update.joomla.org/core/list.xml';
|
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
|
||||||
$updateXml = @file_get_contents($updateUrl);
|
$updateXml = @file_get_contents($updateUrl);
|
||||||
}
|
|
||||||
|
if ($updateXml === false) {
|
||||||
if ($updateXml !== false) {
|
// Fallback: try the LTS feed
|
||||||
// Parse all version entries
|
$updateUrl = 'https://update.joomla.org/core/list.xml';
|
||||||
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
|
$updateXml = @file_get_contents($updateUrl);
|
||||||
$joomlaVersions = $matches[1] ?? [];
|
}
|
||||||
}
|
|
||||||
|
if ($updateXml !== false) {
|
||||||
if (empty($joomlaVersions)) {
|
// Parse all version entries
|
||||||
echo "WARNING: Could not fetch Joomla versions from update server\n";
|
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
|
||||||
echo "Tested URL: {$updateUrl}\n";
|
$joomlaVersions = $matches[1] ?? [];
|
||||||
exit(0);
|
}
|
||||||
}
|
|
||||||
|
if (empty($joomlaVersions)) {
|
||||||
// Sort and get latest
|
echo "WARNING: Could not fetch Joomla versions from update server\n";
|
||||||
usort($joomlaVersions, 'version_compare');
|
echo "Tested URL: {$updateUrl}\n";
|
||||||
$latestJoomla = end($joomlaVersions);
|
return 0;
|
||||||
|
}
|
||||||
echo "Latest Joomla: {$latestJoomla}\n";
|
|
||||||
|
// Sort and get latest
|
||||||
// ── Test compatibility ──────────────────────────────────────────────────
|
usort($joomlaVersions, 'version_compare');
|
||||||
// The targetplatform regex uses Joomla's regex format
|
$latestJoomla = end($joomlaVersions);
|
||||||
// Common patterns: "5\.[0-9]+" or "((5.[0-9])|(6.[0-9]))"
|
|
||||||
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
|
echo "Latest Joomla: {$latestJoomla}\n";
|
||||||
|
|
||||||
if ($compatible === false) {
|
// -- Test compatibility --
|
||||||
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
|
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
|
||||||
$result = 'error';
|
|
||||||
} elseif ($compatible === 1) {
|
if ($compatible === false) {
|
||||||
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
|
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
|
||||||
$result = 'pass';
|
$result = 'error';
|
||||||
} else {
|
} elseif ($compatible === 1) {
|
||||||
// Check which major versions are supported
|
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
|
||||||
$supported = [];
|
$result = 'pass';
|
||||||
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
|
} else {
|
||||||
if (@preg_match("/{$targetRegex}/", $v)) {
|
// Check which major versions are supported
|
||||||
$supported[] = $v;
|
$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';
|
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";
|
||||||
// ── Export ───────────────────────────────────────────────────────────────
|
$result = 'warn';
|
||||||
if ($ghOutput) {
|
}
|
||||||
$ghFile = getenv('GITHUB_OUTPUT');
|
|
||||||
if ($ghFile) {
|
// -- Export --
|
||||||
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND);
|
if ($ghOutput) {
|
||||||
file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND);
|
$ghFile = getenv('GITHUB_OUTPUT');
|
||||||
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
|
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);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result === 'error' ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$app = new JoomlaCompatCheckCli();
|
||||||
|
exit($app->execute());
|
||||||
|
|||||||
+68
-27
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -26,10 +27,18 @@ require_once __DIR__ . '/../vendor/autoload.php';
|
|||||||
|
|
||||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
|
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
class JoomlaRelease extends CliFramework
|
||||||
{
|
{
|
||||||
private const VERSION = '04.06.00';
|
private const VERSION = '09.23.00';
|
||||||
private const ORG = 'mokoconsulting-tech';
|
private const ORG = 'MokoConsulting';
|
||||||
|
|
||||||
private const STABILITY_TAGS = [
|
private const STABILITY_TAGS = [
|
||||||
'development' => 'development',
|
'development' => 'development',
|
||||||
@@ -47,17 +56,17 @@ class JoomlaRelease extends CliFramework
|
|||||||
'stable' => '',
|
'stable' => '',
|
||||||
];
|
];
|
||||||
|
|
||||||
private ApiClient $api;
|
private ApiClient $api;
|
||||||
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml');
|
$this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml');
|
||||||
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
|
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
|
||||||
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
|
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
|
||||||
$this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable');
|
$this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable');
|
||||||
$this->addArgument('--dry-run', 'Preview without making changes', false);
|
$this->addArgument('--dry-run', 'Preview without making changes', false);
|
||||||
$this->addArgument('--verbose', 'Show detailed output', false);
|
$this->addArgument('--verbose', 'Show detailed output', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
@@ -67,7 +76,7 @@ class JoomlaRelease extends CliFramework
|
|||||||
$stability = (string) $this->getArgument('--stability');
|
$stability = (string) $this->getArgument('--stability');
|
||||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
$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)));
|
$this->log('ERROR', "Invalid stability: {$stability}. Use: " . implode(', ', array_keys(self::STABILITY_TAGS)));
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -78,7 +87,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
|
|
||||||
if ($repo !== '') {
|
if ($repo !== '') {
|
||||||
$path = $this->cloneRepo($repo);
|
$path = $this->cloneRepo($repo);
|
||||||
if ($path === null) { return 1; }
|
if ($path === null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$path = rtrim($path, '/\\');
|
$path = rtrim($path, '/\\');
|
||||||
|
|
||||||
@@ -141,10 +152,11 @@ class JoomlaRelease extends CliFramework
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 4: Upload to GitHub Release ──────────────────────────
|
// ── Step 4: Upload to GitHub Release ──────────────────────────
|
||||||
$repoFullName = self::ORG . '/' . ($repo ?: basename($path));
|
$repoFullName = self::ORG . '/' . ($repo ?: basename(realpath($path) ?: $path));
|
||||||
|
|
||||||
if (!$dryRun) {
|
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, $zipPath, $zipName);
|
||||||
$this->uploadAsset($repoFullName, $releaseTag, $tarPath, $tarName);
|
$this->uploadAsset($repoFullName, $releaseTag, $tarPath, $tarName);
|
||||||
$this->log('SUCCESS', "Uploaded to release: {$releaseTag}");
|
$this->log('SUCCESS', "Uploaded to release: {$releaseTag}");
|
||||||
@@ -182,7 +194,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
private function findManifest(string $path): ?string
|
private function findManifest(string $path): ?string
|
||||||
{
|
{
|
||||||
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
|
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
|
||||||
if (!is_dir($dir)) { continue; }
|
if (!is_dir($dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
foreach (glob("{$dir}/*.xml") as $file) {
|
foreach (glob("{$dir}/*.xml") as $file) {
|
||||||
if (str_contains((string) file_get_contents($file), '<extension')) {
|
if (str_contains((string) file_get_contents($file), '<extension')) {
|
||||||
return $file;
|
return $file;
|
||||||
@@ -205,7 +219,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
|
|
||||||
// Templates don't have <element> — derive from <name>
|
// Templates don't have <element> — derive from <name>
|
||||||
if ($element === '') {
|
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 = '';
|
$tp = '';
|
||||||
@@ -224,7 +240,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
private function readVersion(string $path): ?string
|
private function readVersion(string $path): ?string
|
||||||
{
|
{
|
||||||
$readme = "{$path}/README.md";
|
$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)) {
|
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) {
|
||||||
return $m[1];
|
return $m[1];
|
||||||
}
|
}
|
||||||
@@ -290,8 +308,12 @@ class JoomlaRelease extends CliFramework
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Copy package-level files (manifest, script, language)
|
// 2. Copy package-level files (manifest, script, language)
|
||||||
foreach (glob("{$srcDir}/*.xml") as $f) { copy($f, "{$staging}/" . basename($f)); }
|
foreach (glob("{$srcDir}/*.xml") as $f) {
|
||||||
foreach (glob("{$srcDir}/*.php") as $f) { copy($f, "{$staging}/" . basename($f)); }
|
copy($f, "{$staging}/" . basename($f));
|
||||||
|
}
|
||||||
|
foreach (glob("{$srcDir}/*.php") as $f) {
|
||||||
|
copy($f, "{$staging}/" . basename($f));
|
||||||
|
}
|
||||||
foreach (['language', 'administrator'] as $d) {
|
foreach (['language', 'administrator'] as $d) {
|
||||||
if (is_dir("{$srcDir}/{$d}")) {
|
if (is_dir("{$srcDir}/{$d}")) {
|
||||||
$this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
|
$this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
|
||||||
@@ -310,7 +332,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
*/
|
*/
|
||||||
private function copyDir(string $src, string $dst): void
|
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(
|
$iter = new \RecursiveIteratorIterator(
|
||||||
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
|
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
|
||||||
\RecursiveIteratorIterator::SELF_FIRST
|
\RecursiveIteratorIterator::SELF_FIRST
|
||||||
@@ -331,7 +355,9 @@ class JoomlaRelease extends CliFramework
|
|||||||
);
|
);
|
||||||
foreach ($iter as $file) {
|
foreach ($iter as $file) {
|
||||||
$local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname()));
|
$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);
|
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
|
||||||
}
|
}
|
||||||
$zip->close();
|
$zip->close();
|
||||||
@@ -348,24 +374,39 @@ class JoomlaRelease extends CliFramework
|
|||||||
|
|
||||||
private function isExcluded(string $name): bool
|
private function isExcluded(string $name): bool
|
||||||
{
|
{
|
||||||
if ($name === '.ftpignore') { return true; }
|
if ($name === '.ftpignore') {
|
||||||
if (str_starts_with($name, 'sftp-config')) { return true; }
|
return true;
|
||||||
if (str_starts_with($name, '.env')) { return true; }
|
}
|
||||||
|
if (str_starts_with($name, 'sftp-config')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (str_starts_with($name, '.env')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||||
return in_array($ext, ['ppk', 'pem', 'key'], true);
|
return in_array($ext, ['ppk', 'pem', 'key'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GitHub Release ───────────────────────────────────────────────
|
// ── 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 {
|
try {
|
||||||
$this->api->get("/repos/{$repo}/releases/tags/{$tag}");
|
$this->api->get("/repos/{$repo}/releases/tags/{$tag}");
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->api->post("/repos/{$repo}/releases", [
|
$this->api->post("/repos/{$repo}/releases", [
|
||||||
'tag_name' => $tag,
|
'tag_name' => $tag,
|
||||||
'name' => ($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})",
|
'name' => $releaseName,
|
||||||
'body' => "## {$version}\n\nCreated by MokoStandards release pipeline.",
|
'body' => "## {$version}\n\nCreated by moko-platform release pipeline.",
|
||||||
'prerelease' => ($stability !== 'stable'),
|
'prerelease' => ($stability !== 'stable'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -378,7 +419,7 @@ class JoomlaRelease extends CliFramework
|
|||||||
|
|
||||||
foreach ($release['assets'] ?? [] as $asset) {
|
foreach ($release['assets'] ?? [] as $asset) {
|
||||||
if ($asset['name'] === $fileName) {
|
if ($asset['name'] === $fileName) {
|
||||||
$this->api->delete("/repos/{$repo}/releases/assets/{$asset['id']}");
|
$this->api->delete("/repos/{$repo}/releases/{$release['id']}/assets/{$asset['id']}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +468,7 @@ class JoomlaRelease extends CliFramework
|
|||||||
$lines[] = ' <tags>';
|
$lines[] = ' <tags>';
|
||||||
$lines[] = " <tag>{$stability}</tag>";
|
$lines[] = " <tag>{$stability}</tag>";
|
||||||
$lines[] = ' </tags>';
|
$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[] = ' <downloads>';
|
||||||
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$zipUrl}</downloadurl>";
|
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$zipUrl}</downloadurl>";
|
||||||
$lines[] = " <downloadurl type=\"full\" format=\"tar.gz\">{$tarUrl}</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,192 @@
|
|||||||
|
#!/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;
|
||||||
|
|
||||||
|
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(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
|
||||||
|
foreach ($manifestFiles as $file) {
|
||||||
|
$c = file_get_contents($file);
|
||||||
|
if (strpos($c, '<extension') !== false) {
|
||||||
|
$extManifest = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$modFile = null;
|
||||||
|
$modFiles = array_merge(
|
||||||
|
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||||
|
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||||
|
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||||
|
);
|
||||||
|
foreach ($modFiles as $file) {
|
||||||
|
$c = file_get_contents($file);
|
||||||
|
if (strpos($c, 'extends DolibarrModules') !== false) {
|
||||||
|
$modFile = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$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());
|
||||||
+139
-142
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,165 +10,161 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/manifest_read.php
|
* PATH: /cli/manifest_read.php
|
||||||
* VERSION: 04.09.00
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
// -- Argument parsing ---------------------------------------------------------
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$path = '.';
|
|
||||||
$field = null;
|
|
||||||
$mode = 'field'; // field | all | github-output | json
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Locate manifest ----------------------------------------------------------
|
class ManifestReadCli extends CliFramework
|
||||||
$root = realpath($path) ?: $path;
|
{
|
||||||
$manifestFile = null;
|
protected function configure(): void
|
||||||
|
{
|
||||||
// Priority: manifest.xml (current standard)
|
$this->setDescription('Parse manifest.xml and output requested field(s) for CI consumption');
|
||||||
$candidates = [
|
$this->addArgument('--path', 'Repository root path', '.');
|
||||||
"{$root}/.mokogitea/manifest.xml",
|
$this->addArgument('--field', 'Single field name to output', '');
|
||||||
"{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed)
|
$this->addArgument('--all', 'Print all fields as KEY=VALUE lines', false);
|
||||||
"{$root}/.mokogitea/.moko-platform", // legacy v4
|
$this->addArgument('--github-output', 'Append all fields to $GITHUB_OUTPUT', false);
|
||||||
];
|
$this->addArgument('--json', 'Output all fields as JSON', false);
|
||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
|
||||||
if (file_exists($candidate)) {
|
|
||||||
$manifestFile = $candidate;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ($manifestFile === null) {
|
protected function run(): int
|
||||||
fwrite(STDERR, "No manifest found in {$root}
|
{
|
||||||
");
|
$path = $this->getArgument('--path');
|
||||||
exit(1);
|
$field = $this->getArgument('--field');
|
||||||
}
|
$showAll = $this->getArgument('--all');
|
||||||
|
$ghOutput = $this->getArgument('--github-output');
|
||||||
|
$jsonMode = $this->getArgument('--json');
|
||||||
|
|
||||||
// -- Parse XML ----------------------------------------------------------------
|
// Determine mode
|
||||||
$xml = @simplexml_load_file($manifestFile);
|
if ($ghOutput) {
|
||||||
|
$mode = 'github-output';
|
||||||
if ($xml === false) {
|
} elseif ($showAll) {
|
||||||
// Fallback: try YAML format (.mokostandards legacy)
|
$mode = 'all';
|
||||||
$content = file_get_contents($manifestFile);
|
} elseif ($jsonMode) {
|
||||||
$fields = [];
|
$mode = 'json';
|
||||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
} else {
|
||||||
$fields['platform'] = trim($m[1], "
|
$mode = 'field';
|
||||||
|
|
||||||
\"'");
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
echo ($fields[$field] ?? '') . "
|
|
||||||
";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'all':
|
// -- Locate manifest --
|
||||||
foreach ($fields as $k => $v) {
|
$root = realpath($path) ?: $path;
|
||||||
echo "{$k}={$v}
|
$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':
|
if ($manifestFile === null) {
|
||||||
echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "
|
$this->log('ERROR', "No manifest found in {$root}");
|
||||||
";
|
return 1;
|
||||||
break;
|
}
|
||||||
|
|
||||||
case 'github-output':
|
// -- Parse XML --
|
||||||
$outputFile = getenv('GITHUB_OUTPUT');
|
$xml = @simplexml_load_file($manifestFile);
|
||||||
if ($outputFile === false || $outputFile === '') {
|
|
||||||
fwrite(STDERR, "GITHUB_OUTPUT not set — printing to stdout instead
|
if ($xml === false) {
|
||||||
");
|
// Fallback: try YAML format (.mokostandards legacy)
|
||||||
foreach ($fields as $k => $v) {
|
$content = file_get_contents($manifestFile);
|
||||||
// Convert field-name to FIELD_NAME for env var style
|
$fields = [];
|
||||||
$envKey = str_replace('-', '_', $k);
|
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||||
echo "{$envKey}={$v}
|
$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 {
|
} else {
|
||||||
$fh = fopen($outputFile, 'a');
|
// Register namespace for XPath (optional, simple path works without)
|
||||||
foreach ($fields as $k => $v) {
|
$fields = [
|
||||||
$envKey = str_replace('-', '_', $k);
|
'name' => (string)($xml->identity->name ?? ''),
|
||||||
fwrite($fh, "{$envKey}={$v}
|
'display-name' => (string)($xml->identity->{"display-name"} ?? ''),
|
||||||
");
|
'org' => (string)($xml->identity->org ?? ''),
|
||||||
}
|
'description' => (string)($xml->identity->description ?? ''),
|
||||||
fclose($fh);
|
'license' => (string)($xml->identity->license ?? ''),
|
||||||
fwrite(STDERR, "Wrote " . count($fields) . " fields to GITHUB_OUTPUT
|
'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());
|
||||||
|
|||||||
+302
-312
@@ -12,344 +12,334 @@
|
|||||||
* PATH: /cli/package_build.php
|
* PATH: /cli/package_build.php
|
||||||
* BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects
|
* 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.
|
* NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$version = null;
|
|
||||||
$outputDir = '/tmp';
|
|
||||||
$typePrefixOverride = null;
|
|
||||||
$elementOverride = null;
|
|
||||||
$githubOutput = false;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
|
||||||
$path = $argv[$i + 1];
|
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) {
|
protected function run(): int
|
||||||
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n");
|
{
|
||||||
exit(1);
|
$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;
|
if ($version === '') {
|
||||||
|
$this->log('ERROR', 'Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]');
|
||||||
// Ensure output directory exists
|
return 1;
|
||||||
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 ($manifest === null) {
|
$root = realpath($path) ?: $path;
|
||||||
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
|
|
||||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
// Ensure output directory exists
|
||||||
$manifest = $f;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ($manifest !== null) {
|
if ($sourceDir === null) {
|
||||||
$xml = file_get_contents($manifest);
|
$this->log('ERROR', "No src/ or htdocs/ directory found in {$root}");
|
||||||
|
return 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 ($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 ($extElement === null) {
|
if ($extElement === null) {
|
||||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
|
$extElement = strtolower(basename($root));
|
||||||
$extElement = $m[1];
|
}
|
||||||
} elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) {
|
|
||||||
$extElement = $m[1];
|
// Prevent double prefix (e.g. pkg_pkg_mokogallery)
|
||||||
} elseif (preg_match('/module="([^"]+)"/', $xml, $m)) {
|
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
|
||||||
$extElement = $m[1];
|
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
|
||||||
} else {
|
}
|
||||||
$extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
|
||||||
|
$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)) {
|
// Copy package-level files (manifest, script.php, etc.)
|
||||||
$extType = $m[1];
|
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
|
||||||
}
|
copy($f, "{$stagingDir}/" . basename($f));
|
||||||
$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"));
|
// Copy language directory if present
|
||||||
}
|
if (is_dir("{$sourceDir}/language")) {
|
||||||
}
|
$langDest = "{$stagingDir}/language";
|
||||||
|
mkdir($langDest, 0755, true);
|
||||||
if ($extElement === null) {
|
$langIterator = new \RecursiveIteratorIterator(
|
||||||
$extElement = strtolower(basename($root));
|
new \RecursiveDirectoryIterator("{$sourceDir}/language", \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
}
|
\RecursiveIteratorIterator::SELF_FIRST
|
||||||
|
);
|
||||||
// Prevent double prefix (e.g. pkg_pkg_mokogallery)
|
foreach ($langIterator as $item) {
|
||||||
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
|
$target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1);
|
||||||
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
|
if ($item->isDir()) {
|
||||||
}
|
mkdir($target, 0755, true);
|
||||||
|
} else {
|
||||||
$zipName = "{$typePrefix}{$extElement}-{$version}.zip";
|
copy($item->getPathname(), $target);
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create ZIP from staging
|
// Create ZIP from staging
|
||||||
$zip = new ZipArchive();
|
$zip = new \ZipArchive();
|
||||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||||
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
|
$this->log('ERROR', "Failed to create ZIP: {$zipPath}");
|
||||||
exit(1);
|
return 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;
|
|
||||||
}
|
}
|
||||||
}
|
$this->addDirectoryToZip($zip, $stagingDir, '', []);
|
||||||
if ($skip) {
|
$zip->close();
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize path separators for ZIP
|
// Create tar.gz — all arguments are escaped via escapeshellarg()
|
||||||
$relativePath = str_replace('\\', '/', $relativePath);
|
$tarCmd = sprintf(
|
||||||
|
'tar -czf %s -C %s .',
|
||||||
|
escapeshellarg($tarPath),
|
||||||
|
escapeshellarg($stagingDir)
|
||||||
|
);
|
||||||
|
passthru($tarCmd, $tarReturn);
|
||||||
|
|
||||||
if ($file->isDir()) {
|
// Cleanup staging
|
||||||
$zip->addEmptyDir($relativePath);
|
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
|
||||||
|
passthru($cleanCmd);
|
||||||
} else {
|
} 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());
|
||||||
|
|||||||
+40
-23
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,32 +10,48 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/platform_detect.php
|
* PATH: /cli/platform_detect.php
|
||||||
* BRIEF: Detect platform from .mokostandards file — outputs platform string
|
* BRIEF: Detect platform from manifest.xml file — outputs platform string
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
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;
|
||||||
|
|
||||||
|
class PlatformDetectCli extends CliFramework
|
||||||
|
{
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setDescription('Detect platform from manifest.xml file');
|
||||||
|
$this->addArgument('--path', 'Repository root path', '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function run(): int
|
||||||
|
{
|
||||||
|
$path = $this->getArgument('--path');
|
||||||
|
$root = realpath($path) ?: $path;
|
||||||
|
|
||||||
|
// Check .mokogitea/manifest.xml first, fallback to root
|
||||||
|
$file = "{$root}/.mokogitea/manifest.xml";
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
$file = "{$root}/.mokostandards";
|
||||||
|
}
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
echo "unknown\n";
|
||||||
|
return 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$root = realpath($path) ?: $path;
|
$app = new PlatformDetectCli();
|
||||||
// Check .github/.mokostandards first, fallback to root
|
exit($app->execute());
|
||||||
$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);
|
|
||||||
|
|||||||
+171
-150
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,163 +10,183 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/release.php
|
* PATH: /cli/release.php
|
||||||
* BRIEF: Automate the MokoStandards version branch release flow
|
* BRIEF: Automate the moko-platform 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$dryRun = in_array('--dry-run', $argv);
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$bumpType = null;
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--bump' && isset($argv[$i + 1])) {
|
|
||||||
$bumpType = $argv[$i + 1]; // patch | minor | major
|
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);
|
protected function run(): int
|
||||||
$syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php";
|
{
|
||||||
// Check both workflow directories for the bulk-repo-sync workflow
|
$bumpType = $this->getArgument('--bump');
|
||||||
$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml")
|
if (empty($bumpType)) {
|
||||||
? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml"
|
$bumpType = null;
|
||||||
: "{$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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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) ───────────────────
|
$app = new ReleaseCli();
|
||||||
$tag = "v{$currentVersion}";
|
exit($app->execute());
|
||||||
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";
|
|
||||||
|
|||||||
+139
-133
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -10,143 +11,148 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/release_body_update.php
|
* PATH: /cli/release_body_update.php
|
||||||
* BRIEF: Update Gitea release body with changelog extract and checksums
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$version = null;
|
|
||||||
$releaseTag = null;
|
|
||||||
$token = null;
|
|
||||||
$apiBase = null;
|
|
||||||
$zipName = null;
|
|
||||||
$tarName = null;
|
|
||||||
$zipSha = null;
|
|
||||||
$tarSha = null;
|
|
||||||
$outputSummary = false;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
|
||||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
class ReleaseBodyUpdateCli extends CliFramework
|
||||||
if ($arg === '--release-tag' && isset($argv[$i + 1])) $releaseTag = $argv[$i + 1];
|
{
|
||||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
protected function configure(): void
|
||||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
{
|
||||||
if ($arg === '--zip-name' && isset($argv[$i + 1])) $zipName = $argv[$i + 1];
|
$this->setDescription('Update Gitea release body with changelog extract and checksums');
|
||||||
if ($arg === '--tar-name' && isset($argv[$i + 1])) $tarName = $argv[$i + 1];
|
$this->addArgument('--path', 'Repo root for CHANGELOG.md', '.');
|
||||||
if ($arg === '--zip-sha' && isset($argv[$i + 1])) $zipSha = $argv[$i + 1];
|
$this->addArgument('--version', 'Version string', '');
|
||||||
if ($arg === '--tar-sha' && isset($argv[$i + 1])) $tarSha = $argv[$i + 1];
|
$this->addArgument('--release-tag', 'Gitea release tag', '');
|
||||||
if ($arg === '--output-summary') $outputSummary = true;
|
$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;
|
$app = new ReleaseBodyUpdateCli();
|
||||||
|
exit($app->execute());
|
||||||
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);
|
|
||||||
|
|||||||
+20
-99
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,109 +10,29 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/release_cascade.php
|
* PATH: /cli/release_cascade.php
|
||||||
* BRIEF: Delete lesser pre-release channels from Gitea when promoting stability
|
* VERSION: 09.24.00
|
||||||
*
|
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$stability = null;
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$token = null;
|
|
||||||
$apiBase = null;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
|
|
||||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
class ReleaseCascadeCli extends CliFramework
|
||||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
{
|
||||||
|
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
|
$app = new ReleaseCascadeCli();
|
||||||
if ($token === null) {
|
exit($app->execute());
|
||||||
$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);
|
|
||||||
|
|||||||
@@ -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_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;
|
||||||
|
|
||||||
|
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(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
foreach ($manifestFiles as $file) {
|
||||||
|
$c = file_get_contents($file);
|
||||||
|
if ($c !== false && strpos($c, '<extension') !== false) {
|
||||||
|
$extManifest = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find Dolibarr module file
|
||||||
|
$modFile = null;
|
||||||
|
$modFiles = array_merge(
|
||||||
|
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||||
|
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||||
|
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||||
|
);
|
||||||
|
foreach ($modFiles as $file) {
|
||||||
|
$c = file_get_contents($file);
|
||||||
|
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
|
||||||
|
$modFile = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata based on platform
|
||||||
|
switch (true) {
|
||||||
|
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||||
|
$xml = file_get_contents($extManifest);
|
||||||
|
if ($xml === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||||
|
$extType = $tm[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||||
|
$extFolder = $gm[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -10,230 +11,165 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/release_manage.php
|
* PATH: /cli/release_manage.php
|
||||||
* BRIEF: Create/update Gitea releases, upload assets, update release body
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$action = null;
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$tag = null;
|
|
||||||
$name = null;
|
|
||||||
$body = null;
|
|
||||||
$bodyFile = null;
|
|
||||||
$target = 'main';
|
|
||||||
$files = [];
|
|
||||||
$token = null;
|
|
||||||
$apiBase = null;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow token from environment
|
class ReleaseManageCli extends CliFramework
|
||||||
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
|
|
||||||
{
|
{
|
||||||
$ch = curl_init($url);
|
protected function configure(): void
|
||||||
$headers = ["Authorization: token {$token}"];
|
{
|
||||||
|
$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 = [
|
protected function run(): int
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
{
|
||||||
CURLOPT_TIMEOUT => 60,
|
$action = $this->getArgument('--action');
|
||||||
CURLOPT_CUSTOMREQUEST => $method,
|
$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) {
|
private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
|
||||||
$headers[] = 'Content-Type: application/json';
|
{
|
||||||
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
|
$ch = curl_init($url);
|
||||||
} elseif ($filePath !== null) {
|
$headers = ["Authorization: token {$token}"];
|
||||||
$headers[] = 'Content-Type: application/octet-stream';
|
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
|
||||||
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath);
|
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;
|
private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
|
||||||
curl_setopt_array($ch, $opts);
|
{
|
||||||
|
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
|
||||||
$response = curl_exec($ch);
|
return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
}
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
$data = json_decode($response ?: '{}', true) ?: [];
|
|
||||||
return ['code' => $httpCode, 'data' => $data];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
$app = new ReleaseManageCli();
|
||||||
* Get release by tag
|
exit($app->execute());
|
||||||
*/
|
|
||||||
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);
|
|
||||||
|
|||||||
@@ -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
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -14,53 +15,69 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$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];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($version === null) {
|
use MokoEnterprise\CliFramework;
|
||||||
// Read from README.md
|
|
||||||
$readme = realpath($path) . '/README.md';
|
class ReleaseNotesCli extends CliFramework
|
||||||
if (file_exists($readme)) {
|
{
|
||||||
$content = file_get_contents($readme);
|
protected function configure(): void
|
||||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
{
|
||||||
$version = $m[1];
|
$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) {
|
$app = new ReleaseNotesCli();
|
||||||
fwrite(STDERR, "Usage: release_notes.php --path . --version XX.YY.ZZ\n");
|
exit($app->execute());
|
||||||
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);
|
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
#!/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;
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
|
||||||
|
$manifestFiles = array_merge(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.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 && is_dir("{$root}/src")) {
|
||||||
|
$sourceDir = "{$root}/src";
|
||||||
|
} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) {
|
||||||
|
$sourceDir = "{$root}/htdocs";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceDir === null) {
|
||||||
|
echo "No src/ or htdocs/ 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";
|
||||||
|
|
||||||
|
$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, $pkgDir, '', $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;
|
||||||
|
|
||||||
|
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(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
foreach ($manifestFiles as $xmlFile) {
|
||||||
|
$xmlContent = file_get_contents($xmlFile);
|
||||||
|
if (strpos($xmlContent, '<extension') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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.24.00
|
||||||
|
* 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());
|
||||||
+219
-160
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,170 +10,228 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/release_validate.php
|
* PATH: /cli/release_validate.php
|
||||||
* BRIEF: Pre-release validation — version consistency, required files, manifest checks
|
* 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$version = null;
|
|
||||||
$platform = 'joomla';
|
|
||||||
$outputSummary = false;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
|
||||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
class ReleaseValidateCli extends CliFramework
|
||||||
if ($arg === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1];
|
{
|
||||||
if ($arg === '--output-summary') $outputSummary = true;
|
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 = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
|
||||||
|
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ 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;
|
||||||
|
foreach (["{$root}/src", $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 (['src', 'htdocs'] 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) {
|
$app = new ReleaseValidateCli();
|
||||||
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n");
|
exit($app->execute());
|
||||||
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);
|
|
||||||
|
|||||||
+190
-169
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -10,179 +11,199 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/release_verify.php
|
* PATH: /cli/release_verify.php
|
||||||
* BRIEF: Verify a built release artifact — version, SHA256, disallowed files
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$zipPath = null;
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$version = null;
|
|
||||||
$platform = 'joomla';
|
|
||||||
$updatesXml = null;
|
|
||||||
$githubOutput = false;
|
|
||||||
$outputSummary = false;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--zip-path' && isset($argv[$i + 1])) $zipPath = $argv[$i + 1];
|
|
||||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
class ReleaseVerifyCli extends CliFramework
|
||||||
if ($arg === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1];
|
{
|
||||||
if ($arg === '--updates-xml' && isset($argv[$i + 1])) $updatesXml = $argv[$i + 1];
|
private int $pass = 0;
|
||||||
if ($arg === '--github-output') $githubOutput = true;
|
private int $fail = 0;
|
||||||
if ($arg === '--output-summary') $outputSummary = true;
|
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) {
|
$app = new ReleaseVerifyCli();
|
||||||
fwrite(STDERR, "Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]\n");
|
exit($app->execute());
|
||||||
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);
|
|
||||||
|
|||||||
+111
-226
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -11,240 +12,124 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/scaffold_client.php
|
* PATH: /cli/scaffold_client.php
|
||||||
* VERSION: 01.00.00
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
|
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
final class ScaffoldClient
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
|
|
||||||
|
use MokoEnterprise\CliFramework;
|
||||||
|
|
||||||
|
class ScaffoldClientCli extends CliFramework
|
||||||
{
|
{
|
||||||
private string $name = '';
|
protected function configure(): void
|
||||||
private string $org = '';
|
{
|
||||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
$this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
|
||||||
private string $token = '';
|
$this->addArgument('--name', 'Client name', '');
|
||||||
private bool $dryRun = false;
|
$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
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$this->parseArgs();
|
$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 === '')
|
private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void
|
||||||
{
|
{
|
||||||
$this->log('ERROR: --name, --org, and --token are required.');
|
fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\n"
|
||||||
$this->printUsage();
|
. "Navigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\n"
|
||||||
return 1;
|
. "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;
|
private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array
|
||||||
|
{
|
||||||
$this->log("Scaffolding client repo: {$this->org}/{$repoName}");
|
$ch = curl_init();
|
||||||
$this->log("Gitea URL: {$this->giteaUrl}");
|
curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
if ($this->dryRun)
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
{
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]);
|
||||||
$this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
|
if ($body !== null) {
|
||||||
$this->log("[DRY RUN] Repo: {$this->org}/{$repoName}");
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
$this->log("[DRY RUN] Description: \"{$this->name} WaaS site\"");
|
}
|
||||||
$this->log('[DRY RUN] Would create dev branch from main');
|
$responseBody = curl_exec($ch);
|
||||||
$this->printPostSetupInstructions($repoName);
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
return 0;
|
if (curl_errno($ch)) {
|
||||||
}
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
// Step 1: Create repo from template
|
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||||
$this->log('Step 1: Creating repo from template...');
|
}
|
||||||
|
curl_close($ch);
|
||||||
$createPayload = json_encode([
|
return ['code' => $httpCode, 'body' => $responseBody];
|
||||||
'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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$app = new ScaffoldClient();
|
$app = new ScaffoldClientCli();
|
||||||
exit($app->run());
|
exit($app->execute());
|
||||||
|
|||||||
+159
-150
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -12,166 +13,174 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/sync_rulesets.php
|
* PATH: /cli/sync_rulesets.php
|
||||||
* BRIEF: Apply branch protection rules to all repos via platform adapter
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
|
|
||||||
|
use MokoEnterprise\CliFramework;
|
||||||
use MokoEnterprise\Config;
|
use MokoEnterprise\Config;
|
||||||
use MokoEnterprise\PlatformAdapterFactory;
|
use MokoEnterprise\PlatformAdapterFactory;
|
||||||
|
|
||||||
$dryRun = in_array('--dry-run', $argv);
|
class SyncRulesetsCli extends CliFramework
|
||||||
$deleteOld = in_array('--delete', $argv);
|
{
|
||||||
|
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) {
|
$config = Config::load();
|
||||||
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; }
|
$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();
|
$app = new SyncRulesetsCli();
|
||||||
$adapter = PlatformAdapterFactory::create($config);
|
exit($app->execute());
|
||||||
$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);
|
|
||||||
|
|||||||
+193
-209
@@ -1,209 +1,193 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
*
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
*
|
||||||
*
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
* FILE INFORMATION
|
*
|
||||||
* DEFGROUP: moko-platform.CLI
|
* FILE INFORMATION
|
||||||
* INGROUP: moko-platform
|
* DEFGROUP: moko-platform.CLI
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* INGROUP: moko-platform
|
||||||
* PATH: /cli/theme_lint.php
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* BRIEF: Lint theme files — CSS syntax, image sizes, hardcoded URLs
|
* 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
|
declare(strict_types=1);
|
||||||
* php theme_lint.php --path /repo --github-output
|
|
||||||
*
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
* Options:
|
|
||||||
* --path Repository root (default: .)
|
use MokoEnterprise\CliFramework;
|
||||||
* --max-image-kb Maximum image file size in KB (default: 500)
|
|
||||||
* --github-output Export results to $GITHUB_OUTPUT
|
class ThemeLintCli extends CliFramework
|
||||||
* --strict Exit 1 on any warning (default: only on errors)
|
{
|
||||||
*/
|
protected function configure(): void
|
||||||
|
{
|
||||||
declare(strict_types=1);
|
$this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs');
|
||||||
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
$path = '.';
|
$this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500');
|
||||||
$maxImageKb = 500;
|
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
||||||
$ghOutput = false;
|
$this->addArgument('--strict', 'Exit 1 on any warning', false);
|
||||||
$strict = false;
|
}
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
protected function run(): int
|
||||||
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];
|
$path = $this->getArgument('--path');
|
||||||
if ($arg === '--github-output') $ghOutput = true;
|
$maxImageKb = (int) $this->getArgument('--max-image-kb');
|
||||||
if ($arg === '--strict') $strict = true;
|
$ghOutput = (bool) $this->getArgument('--github-output');
|
||||||
}
|
$strict = (bool) $this->getArgument('--strict');
|
||||||
|
|
||||||
$root = realpath($path) ?: $path;
|
$root = realpath($path) ?: $path;
|
||||||
$errors = 0;
|
$errors = 0;
|
||||||
$warnings = 0;
|
$warnings = 0;
|
||||||
|
|
||||||
// ── Find source directory ───────────────────────────────────────────────
|
$srcDir = null;
|
||||||
$srcDir = null;
|
foreach (['src', 'htdocs'] as $d) {
|
||||||
foreach (['src', 'htdocs'] as $d) {
|
if (is_dir("{$root}/{$d}")) {
|
||||||
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; }
|
$srcDir = "{$root}/{$d}";
|
||||||
}
|
break;
|
||||||
if ($srcDir === null) {
|
}
|
||||||
fwrite(STDERR, "No src/ or htdocs/ directory in {$root}\n");
|
}
|
||||||
exit(1);
|
if ($srcDir === null) {
|
||||||
}
|
$this->log('ERROR', "No src/ or htdocs/ directory in {$root}");
|
||||||
|
return 1;
|
||||||
echo "Theme Lint: {$srcDir}\n\n";
|
}
|
||||||
|
|
||||||
// ── Check 1: CSS syntax validation ──────────────────────────────────────
|
echo "Theme Lint: {$srcDir}\n\n";
|
||||||
echo "--- CSS Syntax ---\n";
|
|
||||||
$cssFiles = findFiles($srcDir, '*.css');
|
echo "--- CSS Syntax ---\n";
|
||||||
$cssMinFiles = findFiles($srcDir, '*.min.css');
|
$cssFiles = $this->findFiles($srcDir, '*.css');
|
||||||
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
|
$cssMinFiles = $this->findFiles($srcDir, '*.min.css');
|
||||||
|
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
|
||||||
if (empty($cssToCheck)) {
|
|
||||||
echo " No CSS files to check\n";
|
if (empty($cssToCheck)) {
|
||||||
} else {
|
echo " No CSS files to check\n";
|
||||||
foreach ($cssToCheck as $file) {
|
} else {
|
||||||
$content = file_get_contents($file);
|
foreach ($cssToCheck as $file) {
|
||||||
$relPath = str_replace($root . '/', '', $file);
|
$content = file_get_contents($file);
|
||||||
|
$relPath = str_replace($root . '/', '', $file);
|
||||||
// Check for unmatched braces
|
$openBraces = substr_count($content, '{');
|
||||||
$openBraces = substr_count($content, '{');
|
$closeBraces = substr_count($content, '}');
|
||||||
$closeBraces = substr_count($content, '}');
|
if ($openBraces !== $closeBraces) {
|
||||||
if ($openBraces !== $closeBraces) {
|
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
|
||||||
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
|
$errors++;
|
||||||
$errors++;
|
}
|
||||||
}
|
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
|
||||||
|
$count = count($m[0]);
|
||||||
// Check for empty rules
|
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
|
||||||
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
|
$warnings++;
|
||||||
$count = count($m[0]);
|
}
|
||||||
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
|
$importantCount = substr_count($content, '!important');
|
||||||
$warnings++;
|
if ($importantCount > 10) {
|
||||||
}
|
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
|
||||||
|
$warnings++;
|
||||||
// Check for !important abuse (more than 10 in one file)
|
}
|
||||||
$importantCount = substr_count($content, '!important');
|
}
|
||||||
if ($importantCount > 10) {
|
if ($errors === 0) {
|
||||||
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
|
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
|
||||||
$warnings++;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
|
||||||
if ($errors === 0) {
|
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
|
||||||
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
|
$images = [];
|
||||||
}
|
foreach ($imageExts as $ext) {
|
||||||
}
|
$images = array_merge($images, $this->findFiles($srcDir, $ext));
|
||||||
|
}
|
||||||
// ── Check 2: Image file sizes ───────────────────────────────────────────
|
if (is_dir("{$root}/images")) {
|
||||||
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
|
foreach ($imageExts as $ext) {
|
||||||
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
|
$images = array_merge($images, $this->findFiles("{$root}/images", $ext));
|
||||||
$images = [];
|
}
|
||||||
foreach ($imageExts as $ext) {
|
}
|
||||||
$images = array_merge($images, findFiles($srcDir, $ext));
|
|
||||||
}
|
$oversized = 0;
|
||||||
// Also check root images/ directory
|
$totalSize = 0;
|
||||||
if (is_dir("{$root}/images")) {
|
foreach ($images as $file) {
|
||||||
foreach ($imageExts as $ext) {
|
$size = filesize($file);
|
||||||
$images = array_merge($images, findFiles("{$root}/images", $ext));
|
$totalSize += $size;
|
||||||
}
|
$relPath = str_replace($root . '/', '', $file);
|
||||||
}
|
$sizeKb = round($size / 1024);
|
||||||
|
if ($sizeKb > $maxImageKb) {
|
||||||
$oversized = 0;
|
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
|
||||||
$totalSize = 0;
|
$oversized++;
|
||||||
foreach ($images as $file) {
|
$warnings++;
|
||||||
$size = filesize($file);
|
}
|
||||||
$totalSize += $size;
|
}
|
||||||
$relPath = str_replace($root . '/', '', $file);
|
|
||||||
$sizeKb = round($size / 1024);
|
$totalMb = round($totalSize / 1024 / 1024, 1);
|
||||||
|
echo " " . count($images) . " image(s), {$totalMb}MB total";
|
||||||
if ($sizeKb > $maxImageKb) {
|
if ($oversized > 0) {
|
||||||
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
|
echo ", {$oversized} oversized";
|
||||||
$oversized++;
|
}
|
||||||
$warnings++;
|
echo "\n";
|
||||||
}
|
|
||||||
}
|
echo "\n--- Hardcoded URLs ---\n";
|
||||||
|
$codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js'));
|
||||||
$totalMb = round($totalSize / 1024 / 1024, 1);
|
$codeFiles = array_filter($codeFiles, function ($f) {
|
||||||
echo " " . count($images) . " image(s), {$totalMb}MB total";
|
return !preg_match('/\.min\.(css|js)$/', $f);
|
||||||
if ($oversized > 0) {
|
});
|
||||||
echo ", {$oversized} oversized";
|
$urlPatterns = [
|
||||||
}
|
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
|
||||||
echo "\n";
|
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
|
||||||
|
'/https?:\/\/localhost/' => 'localhost reference',
|
||||||
// ── Check 3: Hardcoded URLs in CSS/JS ───────────────────────────────────
|
];
|
||||||
echo "\n--- Hardcoded URLs ---\n";
|
$urlIssues = 0;
|
||||||
$codeFiles = array_merge(
|
foreach ($codeFiles as $file) {
|
||||||
findFiles($srcDir, '*.css'),
|
$content = file_get_contents($file);
|
||||||
findFiles($srcDir, '*.js')
|
$relPath = str_replace($root . '/', '', $file);
|
||||||
);
|
foreach ($urlPatterns as $pattern => $desc) {
|
||||||
// Exclude minified files
|
if (preg_match_all($pattern, $content, $matches)) {
|
||||||
$codeFiles = array_filter($codeFiles, function($f) {
|
$count = count($matches[0]);
|
||||||
return !preg_match('/\.min\.(css|js)$/', $f);
|
echo " WARN: {$relPath}: {$count} {$desc}\n";
|
||||||
});
|
$urlIssues++;
|
||||||
|
$warnings++;
|
||||||
$urlPatterns = [
|
}
|
||||||
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
|
}
|
||||||
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
|
}
|
||||||
'/https?:\/\/localhost/' => 'localhost reference',
|
if ($urlIssues === 0) {
|
||||||
];
|
echo " OK: No hardcoded URLs found\n";
|
||||||
|
}
|
||||||
$urlIssues = 0;
|
|
||||||
foreach ($codeFiles as $file) {
|
echo "\n=== Summary ===\n";
|
||||||
$content = file_get_contents($file);
|
echo "Errors: {$errors}\n";
|
||||||
$relPath = str_replace($root . '/', '', $file);
|
echo "Warnings: {$warnings}\n";
|
||||||
|
|
||||||
foreach ($urlPatterns as $pattern => $desc) {
|
if ($ghOutput) {
|
||||||
if (preg_match_all($pattern, $content, $matches)) {
|
$ghFile = getenv('GITHUB_OUTPUT');
|
||||||
$count = count($matches[0]);
|
if ($ghFile) {
|
||||||
echo " WARN: {$relPath}: {$count} {$desc}\n";
|
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
|
||||||
$urlIssues++;
|
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
|
||||||
$warnings++;
|
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
|
||||||
}
|
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($urlIssues === 0) {
|
if ($errors > 0) {
|
||||||
echo " OK: No hardcoded URLs found\n";
|
return 1;
|
||||||
}
|
}
|
||||||
|
if ($strict && $warnings > 0) {
|
||||||
// ── Summary ─────────────────────────────────────────────────────────────
|
return 1;
|
||||||
echo "\n=== Summary ===\n";
|
}
|
||||||
echo "Errors: {$errors}\n";
|
return 0;
|
||||||
echo "Warnings: {$warnings}\n";
|
}
|
||||||
|
|
||||||
if ($ghOutput) {
|
private function findFiles(string $dir, string $pattern): array
|
||||||
$ghFile = getenv('GITHUB_OUTPUT');
|
{
|
||||||
if ($ghFile) {
|
$results = [];
|
||||||
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
|
if (!is_dir($dir)) {
|
||||||
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
|
return $results;
|
||||||
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
|
}
|
||||||
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
|
$iterator = new RecursiveIteratorIterator(
|
||||||
}
|
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||||
}
|
);
|
||||||
|
foreach ($iterator as $file) {
|
||||||
if ($errors > 0) {
|
if (fnmatch($pattern, $file->getFilename())) {
|
||||||
exit(1);
|
$results[] = $file->getPathname();
|
||||||
}
|
}
|
||||||
if ($strict && $warnings > 0) {
|
}
|
||||||
exit(1);
|
return $results;
|
||||||
}
|
}
|
||||||
exit(0);
|
}
|
||||||
|
|
||||||
// ── Helper: recursively find files matching a glob pattern ──────────────
|
$app = new ThemeLintCli();
|
||||||
function findFiles(string $dir, string $pattern): array
|
exit($app->execute());
|
||||||
{
|
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|||||||
+395
-315
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -10,352 +11,362 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/updates_xml_build.php
|
* PATH: /cli/updates_xml_build.php
|
||||||
* BRIEF: Generate Joomla updates.xml from extension manifest metadata
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
// -- Argument parsing ---------------------------------------------------------
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$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;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($version === null) {
|
class UpdatesXmlBuildCli extends CliFramework
|
||||||
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n");
|
{
|
||||||
exit(1);
|
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 ---------------------------------------------------
|
if ($version === '') {
|
||||||
$manifest = null;
|
$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
|
// Strip suffix — stability is applied via --stability parameter
|
||||||
$candidates = glob("{$root}/src/pkg_*.xml") ?: [];
|
$version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version);
|
||||||
foreach ($candidates as $f) {
|
|
||||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
|
||||||
$manifest = $f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($manifest === null) {
|
$root = realpath($path) ?: $path;
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($manifest === null) {
|
// -- Read platform from .mokogitea/manifest.xml --------------------------------
|
||||||
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
|
$detectedPlatform = 'joomla';
|
||||||
exit(1);
|
$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 -------------------------------------------------
|
if (empty($org)) {
|
||||||
$xml = file_get_contents($manifest);
|
$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)
|
// -- Fallback: detect org/repo from git remote --------------------------------
|
||||||
$extName = '';
|
if (empty($org) || empty($repo)) {
|
||||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) $extName = $m[1];
|
$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 = '';
|
// -- Locate Joomla manifest ---------------------------------------------------
|
||||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
|
$manifest = null;
|
||||||
|
|
||||||
$extElement = '';
|
$candidates = glob("{$root}/src/pkg_*.xml") ?: [];
|
||||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
|
foreach ($candidates as $f) {
|
||||||
// For packages, prefer <packagename> to avoid pkg_pkg_ duplication
|
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||||
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) $extElement = $m[1];
|
$manifest = $f;
|
||||||
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1];
|
break;
|
||||||
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);
|
|
||||||
|
|
||||||
$extClient = '';
|
if ($manifest === null) {
|
||||||
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) $extClient = $m[1];
|
$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 ($manifest === null && $detectedPlatform === 'joomla') {
|
||||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1];
|
$this->log('ERROR', "No Joomla XML manifest found in {$root}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
$targetPlatform = '';
|
// -- Parse extension metadata -------------------------------------------------
|
||||||
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) $targetPlatform = $m[1];
|
$extName = '';
|
||||||
if (empty($targetPlatform)) {
|
$extType = '';
|
||||||
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
|
$extElement = '';
|
||||||
}
|
$extClient = '';
|
||||||
|
$extFolder = '';
|
||||||
|
$targetPlatform = '';
|
||||||
|
$phpMinimum = '';
|
||||||
|
|
||||||
$phpMinimum = '';
|
if ($manifest !== null) {
|
||||||
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) $phpMinimum = $m[1];
|
$xml = file_get_contents($manifest);
|
||||||
|
|
||||||
// Resolve language key names (e.g. PLG_SYSTEM_MOKOJOOMTOS)
|
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
|
||||||
if (preg_match('/^[A-Z_]+$/', $extName)) {
|
$extName = $m[1];
|
||||||
$iniFiles = [];
|
}
|
||||||
$iterator = new RecursiveIteratorIterator(
|
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
|
||||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
$extType = $m[1];
|
||||||
);
|
}
|
||||||
foreach ($iterator as $file) {
|
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
|
||||||
if (preg_match('/\.sys\.ini$/i', $file->getFilename())) {
|
$extElement = $m[1];
|
||||||
$iniFiles[] = $file->getPathname();
|
}
|
||||||
}
|
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) {
|
||||||
}
|
$extElement = $m[1];
|
||||||
foreach ($iniFiles as $ini) {
|
}
|
||||||
$content = file_get_contents($ini);
|
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) {
|
||||||
if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) {
|
$extElement = $m[1];
|
||||||
$extName = $m[1];
|
}
|
||||||
break;
|
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 (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) {
|
||||||
if (empty($extName)) $extName = $repo ?: basename($root);
|
$extClient = $m[1];
|
||||||
if (empty($extType)) $extType = 'component';
|
}
|
||||||
|
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 --------------------------------------------------------
|
if (empty($extName)) {
|
||||||
$typePrefix = '';
|
$extName = $repo ?: basename($root);
|
||||||
switch ($extType) {
|
}
|
||||||
case 'plugin': $typePrefix = "plg_{$extFolder}_"; break;
|
if (empty($extType)) {
|
||||||
case 'module': $typePrefix = 'mod_'; break;
|
$extType = 'component';
|
||||||
case 'component': $typePrefix = 'com_'; break;
|
}
|
||||||
case 'template': $typePrefix = 'tpl_'; break;
|
|
||||||
case 'library': $typePrefix = 'lib_'; break;
|
|
||||||
case 'package': $typePrefix = 'pkg_'; break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Export to GITHUB_OUTPUT if requested -------------------------------------
|
if (!empty($detectedDisplayName)) {
|
||||||
if ($githubOutput) {
|
$displayName = $detectedDisplayName;
|
||||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
} elseif (!empty($detectedName)) {
|
||||||
$lines = [
|
$displayName = $detectedName;
|
||||||
"ext_element={$extElement}",
|
} else {
|
||||||
"ext_name={$extName}",
|
$displayName = $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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Stability suffix map -----------------------------------------------------
|
// -- Build type prefix --------------------------------------------------------
|
||||||
$stabilitySuffixMap = [
|
$typePrefix = '';
|
||||||
'stable' => '',
|
switch ($extType) {
|
||||||
'rc' => '-rc',
|
case 'plugin':
|
||||||
'beta' => '-beta',
|
$typePrefix = "plg_{$extFolder}_";
|
||||||
'alpha' => '-alpha',
|
break;
|
||||||
'development' => '-dev',
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Joomla <tags><tag> values — maps to Joomla's stabilityTagToInteger()
|
// -- Export to GITHUB_OUTPUT if requested -------------------------------------
|
||||||
$stabilityTagMap = [
|
if ($githubOutput) {
|
||||||
'stable' => 'stable',
|
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||||
'rc' => 'rc',
|
$lines = [
|
||||||
'beta' => 'beta',
|
"ext_element={$extElement}",
|
||||||
'alpha' => 'alpha',
|
"ext_name={$extName}",
|
||||||
'development' => 'dev',
|
"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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gitea release tag names (used in download/info URLs)
|
// -- Stability suffix map -----------------------------------------------------
|
||||||
$releaseTagMap = [
|
$stabilitySuffixMap = [
|
||||||
'stable' => 'stable',
|
'stable' => '',
|
||||||
'rc' => 'release-candidate',
|
'rc' => '-rc',
|
||||||
'beta' => 'beta',
|
'beta' => '-beta',
|
||||||
'alpha' => 'alpha',
|
'alpha' => '-alpha',
|
||||||
'development' => 'development',
|
'development' => '-dev',
|
||||||
];
|
'dev' => '-dev',
|
||||||
|
];
|
||||||
|
|
||||||
// -- Build update entries -----------------------------------------------------
|
$stabilityTagMap = [
|
||||||
// For the primary entry: apply suffix if not stable
|
'stable' => 'stable',
|
||||||
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
|
'rc' => 'rc',
|
||||||
$primaryVersion = $version . $primarySuffix;
|
'beta' => 'beta',
|
||||||
|
'alpha' => 'alpha',
|
||||||
|
'development' => 'dev',
|
||||||
|
'dev' => 'dev',
|
||||||
|
];
|
||||||
|
|
||||||
// Build client tag — only needed for templates and modules (site vs admin).
|
$releaseTagMap = [
|
||||||
// Packages and components don't use client; plugins use folder instead.
|
'stable' => 'stable',
|
||||||
$clientTag = '';
|
'rc' => 'release-candidate',
|
||||||
if (!empty($extClient)) {
|
'beta' => 'beta',
|
||||||
$clientTag = " <client>{$extClient}</client>";
|
'alpha' => 'alpha',
|
||||||
} elseif (in_array($extType, ['template', 'module'])) {
|
'development' => 'development',
|
||||||
$clientTag = ' <client>site</client>';
|
'dev' => 'development',
|
||||||
}
|
];
|
||||||
|
|
||||||
// Build folder tag
|
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
|
||||||
$folderTag = '';
|
$primaryVersion = $version . $primarySuffix;
|
||||||
if (!empty($extFolder) && $extType === 'plugin') {
|
|
||||||
$folderTag = " <folder>{$extFolder}</folder>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// PHP minimum tag
|
$clientTag = '';
|
||||||
$phpTag = '';
|
if (!empty($extClient)) {
|
||||||
if (!empty($phpMinimum)) {
|
$clientTag = " <client>{$extClient}</client>";
|
||||||
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
} else {
|
||||||
}
|
$clientTag = ' <client>site</client>';
|
||||||
|
}
|
||||||
|
|
||||||
// SHA tag
|
$folderTag = '';
|
||||||
$shaTag = '';
|
if (!empty($extFolder) && $extType === 'plugin') {
|
||||||
if (!empty($sha)) {
|
$folderTag = " <folder>{$extFolder}</folder>";
|
||||||
$shaTag = " <sha256>{$sha}</sha256>";
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
$phpTag = '';
|
||||||
* Build a single <update> entry for a given stability tag
|
if (!empty($phpMinimum)) {
|
||||||
*/
|
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Determine which channels to write ----------------------------------------
|
$shaTag = '';
|
||||||
// Stable cascades to all channels; pre-releases cascade down to lower channels.
|
if (!empty($sha)) {
|
||||||
// Each channel entry represents "latest release available at this stability or higher".
|
$shaTag = " <sha256>{$sha}</sha256>";
|
||||||
// When stable releases, ALL channels point to stable (it's the newest for everyone).
|
}
|
||||||
// When RC releases, rc/beta/alpha/dev point to RC; stable is preserved.
|
|
||||||
// When dev releases, only dev is updated; everything else is preserved.
|
|
||||||
$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable'];
|
|
||||||
$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels);
|
|
||||||
if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable
|
|
||||||
|
|
||||||
// Write entries for the current channel AND all lower channels (cascade down)
|
// -- Write ONLY the single channel being released --------------------------------
|
||||||
// All cascaded entries point to the CURRENT release (the highest stability being built)
|
$entries = [];
|
||||||
$entries = [];
|
$giteaTag = $releaseTagMap[$stability] ?? $stability;
|
||||||
$giteaTag = $releaseTagMap[$stability] ?? $stability;
|
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
|
||||||
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
|
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
|
||||||
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
|
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
|
||||||
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
|
$joomlaTag = $stabilityTagMap[$stability] ?? $stability;
|
||||||
|
$changelogUrl = "{$giteaUrl}/{$org}/{$repo}/raw/branch/main/CHANGELOG.md";
|
||||||
|
|
||||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
$entries[] = $this->buildEntry(
|
||||||
$channelName = $allChannels[$i];
|
$joomlaTag,
|
||||||
$joomlaTag = $stabilityTagMap[$channelName] ?? $channelName;
|
$channelVersion,
|
||||||
// Only attach SHA to the primary channel entry
|
$channelDownloadUrl,
|
||||||
$entrySha = ($i === $stabilityIndex) ? $shaTag : '';
|
$displayName,
|
||||||
|
$stability,
|
||||||
|
$extElement,
|
||||||
|
$extType,
|
||||||
|
$clientTag,
|
||||||
|
$folderTag,
|
||||||
|
$channelInfoUrl,
|
||||||
|
$targetPlatform,
|
||||||
|
$phpTag,
|
||||||
|
$shaTag,
|
||||||
|
$changelogUrl
|
||||||
|
);
|
||||||
|
|
||||||
$entries[] = buildEntry(
|
// -- Preserve existing entries for channels not being updated -----------------
|
||||||
$joomlaTag,
|
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||||
$channelVersion,
|
$preservedEntries = [];
|
||||||
$channelDownloadUrl,
|
|
||||||
$extName,
|
|
||||||
$extElement,
|
|
||||||
$extType,
|
|
||||||
$clientTag,
|
|
||||||
$folderTag,
|
|
||||||
$channelInfoUrl,
|
|
||||||
$targetPlatform,
|
|
||||||
$phpTag,
|
|
||||||
$entrySha
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Preserve existing entries for channels not being updated -----------------
|
if (file_exists($dest)) {
|
||||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
$existingXml = @simplexml_load_file($dest);
|
||||||
$preservedEntries = [];
|
if ($existingXml) {
|
||||||
|
$writtenTag = $joomlaTag;
|
||||||
|
$writtenAliases = [$writtenTag];
|
||||||
|
if ($writtenTag === 'dev') {
|
||||||
|
$writtenAliases[] = 'development';
|
||||||
|
}
|
||||||
|
if ($writtenTag === 'development') {
|
||||||
|
$writtenAliases[] = 'dev';
|
||||||
|
}
|
||||||
|
|
||||||
if (file_exists($dest)) {
|
foreach ($existingXml->update as $existingUpdate) {
|
||||||
$existingXml = @simplexml_load_file($dest);
|
$existingTag = '';
|
||||||
if ($existingXml) {
|
if (isset($existingUpdate->tags->tag)) {
|
||||||
// Joomla tags we're writing — don't preserve these
|
$existingTag = (string) $existingUpdate->tags->tag;
|
||||||
$writtenChannels = [];
|
}
|
||||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
if (!empty($existingTag) && !in_array($existingTag, $writtenAliases, true)) {
|
||||||
$writtenChannels[] = $stabilityTagMap[$allChannels[$i]] ?? $allChannels[$i];
|
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($existingXml->update as $existingUpdate) {
|
// -- Write updates.xml --------------------------------------------------------
|
||||||
$existingTag = '';
|
$year = date('Y');
|
||||||
if (isset($existingUpdate->tags->tag)) {
|
$output = <<<XML
|
||||||
$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
|
|
||||||
<?xml version='1.0' encoding='UTF-8'?>
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
<!-- Copyright (C) {$year} Moko Consulting <hello@mokoconsulting.tech>
|
<!-- Copyright (C) {$year} Moko Consulting <hello@mokoconsulting.tech>
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -364,13 +375,82 @@ $output = <<<XML
|
|||||||
|
|
||||||
<updates>
|
<updates>
|
||||||
XML;
|
XML;
|
||||||
$allEntries = array_merge($preservedEntries, $entries);
|
$allEntries = array_merge($preservedEntries, $entries);
|
||||||
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
|
|
||||||
|
|
||||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
$stabilityOrder = ['dev' => 0, 'development' => 0, 'alpha' => 1, 'beta' => 2, 'rc' => 3, 'stable' => 4];
|
||||||
file_put_contents($dest, $output);
|
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);
|
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
|
||||||
echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n";
|
|
||||||
echo "Output: {$dest}\n";
|
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||||
exit(0);
|
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
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,161 +10,219 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/updates_xml_sync.php
|
* PATH: /cli/updates_xml_sync.php
|
||||||
* VERSION: 05.00.01
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Sync updates.xml to target branches via Gitea API
|
* BRIEF: Sync updates.xml to target branches via Gitea API
|
||||||
* NOTE: Called by pre-release and auto-release workflows after updates.xml
|
* NOTE: Called by pre-release and auto-release workflows after updates.xml
|
||||||
* is modified on the current branch. Pushes the file to other branches
|
* is modified on the current branch. Pushes the file to other branches
|
||||||
* without requiring a git checkout (avoids merge conflicts).
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
// ── Argument parsing ────────────────────────────────────────────────────
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$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') ?: '';
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($current === '') {
|
class UpdatesXmlSyncCli extends CliFramework
|
||||||
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
|
|
||||||
{
|
{
|
||||||
$resp = apiCall('GET', "{$apiBase}/contents/updates.xml?ref={$branch}", $token);
|
protected function configure(): void
|
||||||
return $resp['sha'] ?? null;
|
{
|
||||||
|
$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,
|
$app = new UpdatesXmlSyncCli();
|
||||||
string $encoded, string $sha, string $msg): bool
|
exit($app->execute());
|
||||||
{
|
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/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.24.00
|
||||||
|
* 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
|
||||||
|
$versionOutput = [];
|
||||||
|
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($path) . " 2>&1", $versionOutput, $versionRc);
|
||||||
|
$version = trim($versionOutput[0] ?? '');
|
||||||
|
|
||||||
|
if (empty($version)) {
|
||||||
|
echo "No version found — skipping\n";
|
||||||
|
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());
|
||||||
+234
-91
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,106 +10,248 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/version_bump.php
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$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';
|
|
||||||
}
|
|
||||||
|
|
||||||
$root = realpath($path) ?: $path;
|
use MokoEnterprise\CliFramework;
|
||||||
|
|
||||||
// ── Read version from README.md ──────────────────────────────────────────────
|
class VersionBumpCli extends CliFramework
|
||||||
$readmeVersion = null;
|
{
|
||||||
$readme = "{$root}/README.md";
|
protected function configure(): void
|
||||||
$readmeContent = '';
|
{
|
||||||
if (file_exists($readme)) {
|
$this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files');
|
||||||
$readmeContent = file_get_contents($readme);
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
|
$this->addArgument('--minor', 'Bump minor version', false);
|
||||||
$readmeVersion = $m[1];
|
$this->addArgument('--major', 'Bump major version', false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Read version from Joomla manifest XML ────────────────────────────────────
|
protected function run(): int
|
||||||
$manifestVersion = null;
|
{
|
||||||
|
$path = $this->getArgument('--path');
|
||||||
// Check package manifest first (pkg_*.xml), then sub-extension manifests
|
$type = 'patch';
|
||||||
$manifestFiles = array_merge(
|
if ($this->getArgument('--minor')) {
|
||||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
$type = 'minor';
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
if ($this->getArgument('--major')) {
|
||||||
|
$type = 'major';
|
||||||
|
}
|
||||||
|
$root = realpath($path) ?: $path;
|
||||||
|
$mokoVersion = null;
|
||||||
|
$existingSuffix = '';
|
||||||
|
$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] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$manifestVersion = null;
|
||||||
|
$manifestFiles = array_merge(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
|
||||||
|
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
foreach ($manifestFiles as $xmlFile) {
|
||||||
|
$xmlContent = file_get_contents($xmlFile);
|
||||||
|
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||||
|
continue;
|
||||||
|
} if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
|
||||||
|
$candidate = $xm[1];
|
||||||
|
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||||
|
$manifestVersion = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$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)) {
|
||||||
|
$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 = [];
|
||||||
|
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $pattern) {
|
||||||
|
foreach (glob($pattern) ?: [] as $xmlFile) {
|
||||||
|
$content = file_get_contents($xmlFile);
|
||||||
|
if (strpos($content, '<extension') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$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);
|
||||||
|
$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);
|
||||||
|
$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'];
|
||||||
|
$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 ───────────────────────────────────────────
|
$app = new VersionBumpCli();
|
||||||
$baseVersion = null;
|
exit($app->execute());
|
||||||
|
|
||||||
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);
|
|
||||||
|
|||||||
+201
-233
@@ -1,233 +1,201 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
*
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
*
|
||||||
*
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
* FILE INFORMATION
|
*
|
||||||
* DEFGROUP: moko-platform.CLI
|
* FILE INFORMATION
|
||||||
* INGROUP: moko-platform
|
* DEFGROUP: moko-platform.CLI
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* INGROUP: moko-platform
|
||||||
* PATH: /cli/version_bump_remote.php
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* BRIEF: Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API
|
* 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
|
declare(strict_types=1);
|
||||||
* php version_bump_remote.php --path . --branch dev --bump minor --no-changelog --token TOKEN --api-base URL
|
|
||||||
*
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
* Options:
|
|
||||||
* --path Repository root (reads current version from local manifest)
|
use MokoEnterprise\CliFramework;
|
||||||
* --branch Target branch to bump (required, e.g. dev)
|
|
||||||
* --bump Bump type: patch | minor | major (default: minor)
|
class VersionBumpRemoteCli extends CliFramework
|
||||||
* --token Gitea API token (or GA_TOKEN env var)
|
{
|
||||||
* --api-base Gitea API base URL for the repo
|
protected function configure(): void
|
||||||
* --no-changelog Skip CHANGELOG.md bump
|
{
|
||||||
* --repo Repository path (owner/repo) for API base construction
|
$this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API');
|
||||||
* --gitea-url Gitea instance URL (default: env GITEA_URL)
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
*/
|
$this->addArgument('--branch', 'Target branch to bump (required)', null);
|
||||||
|
$this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor');
|
||||||
declare(strict_types=1);
|
$this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null);
|
||||||
|
$this->addArgument('--api-base', 'Gitea API base URL for the repo', null);
|
||||||
$path = '.';
|
$this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false);
|
||||||
$branch = null;
|
$this->addArgument('--repo', 'Repository path (owner/repo)', null);
|
||||||
$bumpType = 'minor';
|
$this->addArgument('--gitea-url', 'Gitea instance URL', null);
|
||||||
$token = null;
|
}
|
||||||
$apiBase = null;
|
|
||||||
$noChangelog = false;
|
protected function run(): int
|
||||||
$repo = null;
|
{
|
||||||
$giteaUrl = null;
|
$path = $this->getArgument('--path');
|
||||||
|
$branch = $this->getArgument('--branch');
|
||||||
foreach ($argv as $i => $arg) {
|
$bumpType = $this->getArgument('--bump');
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
$token = $this->getArgument('--token');
|
||||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
$apiBase = $this->getArgument('--api-base');
|
||||||
if ($arg === '--bump' && isset($argv[$i + 1])) $bumpType = $argv[$i + 1];
|
$noChangelog = (bool) $this->getArgument('--no-changelog');
|
||||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
$repo = $this->getArgument('--repo');
|
||||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
$giteaUrl = $this->getArgument('--gitea-url');
|
||||||
if ($arg === '--no-changelog') $noChangelog = true;
|
if ($token === null) {
|
||||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
|
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
|
}
|
||||||
}
|
if ($giteaUrl === null) {
|
||||||
|
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||||
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 ($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;
|
||||||
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");
|
$root = realpath($path) ?: $path;
|
||||||
fwrite(STDERR, " or: version_bump_remote.php --branch BRANCH --token TOKEN --repo owner/repo\n");
|
$version = null;
|
||||||
exit(1);
|
$manifestFile = null;
|
||||||
}
|
foreach (["{$root}/src", $root] as $dir) {
|
||||||
|
if (!is_dir($dir)) {
|
||||||
$root = realpath($path) ?: $path;
|
continue;
|
||||||
|
}
|
||||||
// ── Read current version from local manifest ────────────────────────────
|
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||||
$version = null;
|
$xml = file_get_contents($f);
|
||||||
$manifestFile = null;
|
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
|
||||||
|
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $xml, $m)) {
|
||||||
$searchDirs = ["{$root}/src", $root];
|
if ($version === null || version_compare($m[1], $version, '>')) {
|
||||||
foreach ($searchDirs as $dir) {
|
$version = $m[1];
|
||||||
if (!is_dir($dir)) continue;
|
$manifestFile = basename($f);
|
||||||
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];
|
if ($version === null) {
|
||||||
$manifestFile = basename($f);
|
$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;
|
||||||
|
}
|
||||||
if ($version === null) {
|
$major = (int)$parts[1];
|
||||||
fwrite(STDERR, "No version found in manifest XML\n");
|
$minor = (int)$parts[2];
|
||||||
exit(1);
|
$patch = (int)$parts[3];
|
||||||
}
|
switch ($bumpType) {
|
||||||
|
case 'major':
|
||||||
// ── Compute next version ────────────────────────────────────────────────
|
$major++;
|
||||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) {
|
$minor = 0;
|
||||||
fwrite(STDERR, "Invalid version format: {$version}\n");
|
$patch = 0;
|
||||||
exit(1);
|
break;
|
||||||
}
|
case 'minor':
|
||||||
|
$minor++;
|
||||||
$major = (int)$parts[1];
|
$patch = 0;
|
||||||
$minor = (int)$parts[2];
|
break;
|
||||||
$patch = (int)$parts[3];
|
default:
|
||||||
|
$patch++;
|
||||||
switch ($bumpType) {
|
break;
|
||||||
case 'major': $major++; $minor = 0; $patch = 0; break;
|
}
|
||||||
case 'minor': $minor++; $patch = 0; break;
|
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||||
default: $patch++; break;
|
echo "{$version} -> {$nextVersion} ({$branch})\n";
|
||||||
}
|
|
||||||
|
$manifestPaths = [];
|
||||||
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
if ($manifestFile !== null) {
|
||||||
echo "{$version} -> {$nextVersion} ({$branch})\n";
|
$manifestPaths[] = "src/{$manifestFile}";
|
||||||
|
}
|
||||||
// ── Helper: Gitea API request ───────────────────────────────────────────
|
$manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']);
|
||||||
function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
|
$manifestUpdated = false;
|
||||||
{
|
foreach ($manifestPaths as $mPath) {
|
||||||
$ch = curl_init($url);
|
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string {
|
||||||
curl_setopt_array($ch, [
|
return str_replace("<version>{$version}</version>", "<version>{$nextVersion}</version>", $content);
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
}, "chore(version): bump {$version} -> {$nextVersion} [skip ci]");
|
||||||
CURLOPT_HTTPHEADER => [
|
if ($result) {
|
||||||
"Authorization: token {$token}",
|
$manifestUpdated = true;
|
||||||
'Content-Type: application/json',
|
break;
|
||||||
],
|
}
|
||||||
CURLOPT_CUSTOMREQUEST => $method,
|
}
|
||||||
CURLOPT_TIMEOUT => 30,
|
if (!$manifestUpdated) {
|
||||||
]);
|
$this->log('WARN', "could not update manifest on {$branch}");
|
||||||
if ($body !== null) {
|
}
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
if (!$noChangelog) {
|
||||||
}
|
$this->updateRemoteFile($apiBase, $token, 'CHANGELOG.md', $branch, function (string $content) use ($version, $nextVersion): string {
|
||||||
$response = curl_exec($ch);
|
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
if (strpos($content, '[Unreleased]') === false && strpos($content, "## [{$nextVersion}]") === false) {
|
||||||
curl_close($ch);
|
$marker = "## [{$version}]";
|
||||||
|
if (strpos($content, $marker) !== false) {
|
||||||
if ($httpCode >= 400 || $response === false) {
|
$header = "## [{$nextVersion}] - Unreleased\n\n"
|
||||||
return null;
|
. "### Added\n\n### Changed\n\n"
|
||||||
}
|
. "### Fixed\n\n";
|
||||||
return json_decode($response, true) ?: [];
|
$content = str_replace(
|
||||||
}
|
$marker,
|
||||||
|
$header . $marker,
|
||||||
// ── Helper: Update a file on a remote branch ────────────────────────────
|
$content
|
||||||
function updateRemoteFile(
|
);
|
||||||
string $apiBase,
|
}
|
||||||
string $token,
|
}
|
||||||
string $filePath,
|
return $content;
|
||||||
string $branch,
|
}, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]");
|
||||||
callable $transform,
|
}
|
||||||
string $commitMessage
|
return 0;
|
||||||
): bool {
|
}
|
||||||
$url = "{$apiBase}/contents/{$filePath}?ref={$branch}";
|
|
||||||
$file = giteaApi('GET', $url, $token);
|
private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
|
||||||
if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
|
{
|
||||||
return false;
|
$ch = curl_init($url);
|
||||||
}
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
$content = base64_decode($file['content']);
|
CURLOPT_HTTPHEADER => [
|
||||||
$newContent = $transform($content);
|
"Authorization: token {$token}",
|
||||||
|
'Content-Type: application/json',
|
||||||
if ($newContent === $content) {
|
],
|
||||||
fwrite(STDERR, " {$filePath}: no changes needed\n");
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
return true;
|
CURLOPT_TIMEOUT => 30,
|
||||||
}
|
]);
|
||||||
|
if ($body !== null) {
|
||||||
$payload = json_encode([
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
'content' => base64_encode($newContent),
|
}
|
||||||
'sha' => $file['sha'],
|
$response = curl_exec($ch);
|
||||||
'message' => $commitMessage,
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
'branch' => $branch,
|
curl_close($ch);
|
||||||
]);
|
if ($httpCode >= 400 || $response === false) {
|
||||||
|
return null;
|
||||||
$result = giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
|
}
|
||||||
if ($result === null) {
|
return json_decode($response, true) ?: [];
|
||||||
fwrite(STDERR, " {$filePath}: failed to update\n");
|
}
|
||||||
return false;
|
|
||||||
}
|
private function updateRemoteFile(
|
||||||
|
string $apiBase,
|
||||||
echo " {$filePath}: updated on {$branch}\n";
|
string $token,
|
||||||
return true;
|
string $filePath,
|
||||||
}
|
string $branch,
|
||||||
|
callable $transform,
|
||||||
// ── Update manifest XML on the remote branch ────────────────────────────
|
string $commitMessage
|
||||||
$manifestPaths = [];
|
): bool {
|
||||||
if ($manifestFile !== null) {
|
$file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token);
|
||||||
$manifestPaths[] = "src/{$manifestFile}";
|
if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
|
||||||
}
|
return false;
|
||||||
$manifestPaths = array_merge($manifestPaths, [
|
}
|
||||||
'src/templateDetails.xml',
|
$content = base64_decode($file['content']);
|
||||||
'src/manifest.xml',
|
$newContent = $transform($content);
|
||||||
]);
|
if ($newContent === $content) {
|
||||||
|
$this->log('INFO', "{$filePath}: no changes needed");
|
||||||
$manifestUpdated = false;
|
return true;
|
||||||
foreach ($manifestPaths as $mPath) {
|
}
|
||||||
$result = updateRemoteFile(
|
$payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]);
|
||||||
$apiBase, $token, $mPath, $branch,
|
$result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
|
||||||
function (string $content) use ($version, $nextVersion): string {
|
if ($result === null) {
|
||||||
return str_replace(
|
$this->log('ERROR', "{$filePath}: failed to update");
|
||||||
"<version>{$version}</version>",
|
return false;
|
||||||
"<version>{$nextVersion}</version>",
|
}
|
||||||
$content
|
echo " {$filePath}: updated on {$branch}\n";
|
||||||
);
|
return true;
|
||||||
},
|
}
|
||||||
"chore(version): bump {$version} -> {$nextVersion} [skip ci]"
|
}
|
||||||
);
|
|
||||||
if ($result) {
|
$app = new VersionBumpRemoteCli();
|
||||||
$manifestUpdated = true;
|
exit($app->execute());
|
||||||
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);
|
|
||||||
|
|||||||
+175
-110
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,130 +10,194 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/version_check.php
|
* PATH: /cli/version_check.php
|
||||||
* VERSION: 05.00.00
|
* VERSION: 09.24.00
|
||||||
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* php version_check.php --path /repo
|
|
||||||
* php version_check.php --path /repo --strict # exit 1 on mismatch
|
|
||||||
* php version_check.php --path /repo --fix # fix mismatches to highest version
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$strict = false;
|
|
||||||
$fix = false;
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
|
||||||
if ($arg === '--strict') $strict = true;
|
|
||||||
if ($arg === '--fix') $fix = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$root = realpath($path) ?: $path;
|
class VersionCheckCli extends CliFramework
|
||||||
$errors = 0;
|
{
|
||||||
$versions = [];
|
protected function configure(): void
|
||||||
|
{
|
||||||
// ── Read README.md version ───────────────────────────────────────────────────
|
$this->setDescription('Validate version consistency across README, manifests, and sub-packages');
|
||||||
$readme = "{$root}/README.md";
|
$this->addArgument('--path', 'Repository root', '.');
|
||||||
if (file_exists($readme)) {
|
$this->addArgument('--strict', 'Exit 1 on mismatch', false);
|
||||||
$content = file_get_contents($readme);
|
$this->addArgument('--fix', 'Fix mismatches to highest version', false);
|
||||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
|
||||||
$versions['README.md'] = $m[1];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Read manifest XML versions ───────────────────────────────────────────────
|
protected function run(): int
|
||||||
$xmlGlobs = [
|
{
|
||||||
"{$root}/src/pkg_*.xml",
|
$path = $this->getArgument('--path');
|
||||||
"{$root}/src/*.xml",
|
$strict = (bool) $this->getArgument('--strict');
|
||||||
"{$root}/src/packages/*/*.xml",
|
$fix = (bool) $this->getArgument('--fix');
|
||||||
"{$root}/*.xml",
|
$root = realpath($path) ?: $path;
|
||||||
];
|
$errors = 0;
|
||||||
|
$versions = [];
|
||||||
foreach ($xmlGlobs as $glob) {
|
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||||
foreach (glob($glob) ?: [] as $file) {
|
if (file_exists($mokoManifest)) {
|
||||||
// Skip updates.xml
|
$xml = @simplexml_load_file($mokoManifest);
|
||||||
if (basename($file) === 'updates.xml') continue;
|
if ($xml !== false) {
|
||||||
|
$v = (string)($xml->identity->version ?? '');
|
||||||
$xmlContent = file_get_contents($file);
|
$base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v);
|
||||||
if (strpos($xmlContent, '<extension') === false) continue;
|
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) {
|
||||||
|
$versions['.mokogitea/manifest.xml'] = $base;
|
||||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
}
|
||||||
$relPath = str_replace($root . '/', '', $file);
|
}
|
||||||
$relPath = str_replace($root . '\\', '', $relPath);
|
|
||||||
$versions[$relPath] = $xm[1];
|
|
||||||
}
|
}
|
||||||
}
|
$readme = "{$root}/README.md";
|
||||||
}
|
if (file_exists($readme)) {
|
||||||
|
|
||||||
if (empty($versions)) {
|
|
||||||
fwrite(STDERR, "No version sources found\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Compare versions ─────────────────────────────────────────────────────────
|
|
||||||
$uniqueVersions = array_unique(array_values($versions));
|
|
||||||
$highestVersion = '00.00.00';
|
|
||||||
foreach ($versions as $v) {
|
|
||||||
if (version_compare($v, $highestVersion, '>')) {
|
|
||||||
$highestVersion = $v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "=== Version Consistency Check ===\n";
|
|
||||||
foreach ($versions as $source => $ver) {
|
|
||||||
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
|
|
||||||
if ($status === 'MISMATCH') $errors++;
|
|
||||||
echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($uniqueVersions) === 1) {
|
|
||||||
echo "\nAll {$ver} — consistent.\n";
|
|
||||||
} else {
|
|
||||||
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
|
|
||||||
|
|
||||||
if ($fix) {
|
|
||||||
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
|
|
||||||
|
|
||||||
// Fix README.md
|
|
||||||
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
|
|
||||||
$content = file_get_contents($readme);
|
$content = file_get_contents($readme);
|
||||||
$content = preg_replace(
|
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
$versions['README.md'] = $m[1];
|
||||||
'${1}' . $highestVersion,
|
}
|
||||||
$content,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
file_put_contents($readme, $content);
|
|
||||||
echo " Fixed: README.md -> {$highestVersion}\n";
|
|
||||||
}
|
}
|
||||||
|
$changelog = "{$root}/CHANGELOG.md";
|
||||||
// Fix XML manifests
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/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) {
|
foreach ($versions as $source => $ver) {
|
||||||
if ($source === 'README.md') continue;
|
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
|
||||||
if ($ver === $highestVersion) continue;
|
if ($status === 'MISMATCH') {
|
||||||
|
$errors++;
|
||||||
$file = "{$root}/{$source}";
|
} echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
|
||||||
if (!file_exists($file)) continue;
|
|
||||||
|
|
||||||
$content = file_get_contents($file);
|
|
||||||
$content = preg_replace(
|
|
||||||
'|<version>[^<]*</version>|',
|
|
||||||
"<version>{$highestVersion}</version>",
|
|
||||||
$content
|
|
||||||
);
|
|
||||||
file_put_contents($file, $content);
|
|
||||||
echo " Fixed: {$source} -> {$highestVersion}\n";
|
|
||||||
}
|
}
|
||||||
|
if (count($uniqueVersions) === 1) {
|
||||||
echo "Done.\n";
|
echo "\nAll {$ver} -- consistent.\n";
|
||||||
|
} else {
|
||||||
|
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
|
||||||
|
if ($fix) {
|
||||||
|
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
|
||||||
|
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($readme);
|
||||||
|
$updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', '${1}' . $highestVersion, $content, 1);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($readme, $updated);
|
||||||
|
} echo " Fixed: README.md -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
if (isset($versions['.mokogitea/manifest.xml']) && $versions['.mokogitea/manifest.xml'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($mokoManifest);
|
||||||
|
$vPat = '#<version>\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
|
||||||
|
$updated = preg_replace(
|
||||||
|
$vPat,
|
||||||
|
"<version>{$highestVersion}</version>",
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($mokoManifest, $updated);
|
||||||
|
} echo " Fixed: .mokogitea/manifest.xml -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
if (isset($versions['CHANGELOG.md']) && $versions['CHANGELOG.md'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($changelog);
|
||||||
|
$clPat = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?/m';
|
||||||
|
$updated = preg_replace(
|
||||||
|
$clPat,
|
||||||
|
'${1}' . $highestVersion,
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($changelog, $updated);
|
||||||
|
} echo " Fixed: CHANGELOG.md -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($packageJson);
|
||||||
|
$pkPat = '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||||
|
$updated = preg_replace(
|
||||||
|
$pkPat,
|
||||||
|
'${1}' . $highestVersion . '${2}',
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($packageJson, $updated);
|
||||||
|
} echo " Fixed: package.json -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) {
|
||||||
|
$content = file_get_contents($pyproject);
|
||||||
|
$pyPat = '/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}'
|
||||||
|
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||||
|
$updated = preg_replace(
|
||||||
|
$pyPat,
|
||||||
|
'${1}' . $highestVersion . '${2}',
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($pyproject, $updated);
|
||||||
|
} echo " Fixed: pyproject.toml -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
foreach ($versions as $source => $ver) {
|
||||||
|
if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) {
|
||||||
|
continue;
|
||||||
|
} if ($ver === $highestVersion) {
|
||||||
|
continue;
|
||||||
|
} $file = "{$root}/{$source}";
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
continue;
|
||||||
|
} $content = file_get_contents($file);
|
||||||
|
$updated = preg_replace('#<version>[^<]*</version>#', "<version>{$highestVersion}</version>", $content);
|
||||||
|
if ($updated !== null) {
|
||||||
|
file_put_contents($file, $updated);
|
||||||
|
} echo " Fixed: {$source} -> {$highestVersion}\n";
|
||||||
|
}
|
||||||
|
echo "Done.\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($strict && $errors > 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($strict && $errors > 0) {
|
$app = new VersionCheckCli();
|
||||||
exit(1);
|
exit($app->execute());
|
||||||
}
|
|
||||||
|
|
||||||
exit(0);
|
|
||||||
|
|||||||
+136
-52
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -9,66 +10,149 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/version_read.php
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
foreach ($argv as $i => $arg) {
|
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
use MokoEnterprise\CliFramework;
|
||||||
$path = $argv[$i + 1];
|
|
||||||
|
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 ──────────────────────────────────────────────────────
|
// -- 1. Read from .mokogitea/manifest.xml (canonical source) --
|
||||||
$readmeVersion = null;
|
$mokoVersion = null;
|
||||||
$readme = "{$root}/README.md";
|
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||||
if (file_exists($readme)) {
|
if (file_exists($mokoManifest)) {
|
||||||
$content = file_get_contents($readme);
|
$xml = @simplexml_load_file($mokoManifest);
|
||||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
if ($xml !== false) {
|
||||||
$readmeVersion = $m[1];
|
$v = (string)($xml->identity->version ?? '');
|
||||||
}
|
if (preg_match('/^\d{2}\.\d{2}\.\d{2}((?:-(?:dev|alpha|beta|rc))+)?$/', $v)) {
|
||||||
}
|
$mokoVersion = $v;
|
||||||
|
}
|
||||||
// ── 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||||
|
$readmeVersion = $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 3. Fallback: Joomla manifest XML --
|
||||||
|
$manifestVersion = null;
|
||||||
|
$manifestFiles = array_merge(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($manifestFiles as $xmlFile) {
|
||||||
|
$xmlContent = file_get_contents($xmlFile);
|
||||||
|
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?)</version>#', $xmlContent, $xm)) {
|
||||||
|
$candidate = $xm[1];
|
||||||
|
$candidateBase = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $candidate);
|
||||||
|
$currentBase = $manifestVersion ? preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $manifestVersion) : null;
|
||||||
|
if ($currentBase === null || version_compare($candidateBase, $currentBase, '>')) {
|
||||||
|
$manifestVersion = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 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 ────────────────────────────────────────────────
|
$app = new VersionReadCli();
|
||||||
$version = null;
|
exit($app->execute());
|
||||||
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);
|
|
||||||
|
|||||||
@@ -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());
|
||||||
+153
-139
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
@@ -10,160 +11,173 @@
|
|||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/version_set_platform.php
|
* PATH: /cli/version_set_platform.php
|
||||||
* BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla <version>)
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
$path = '.';
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||||
$version = null;
|
|
||||||
$branch = null;
|
|
||||||
$stability = 'stable';
|
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
use MokoEnterprise\CliFramework;
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-detect branch from git or GitHub env
|
class VersionSetPlatformCli extends CliFramework
|
||||||
if ($branch === null) {
|
{
|
||||||
$branch = trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
|
protected function configure(): void
|
||||||
if (empty($branch) || $branch === 'HEAD') {
|
{
|
||||||
$branch = getenv('GITHUB_REF_NAME') ?: 'main';
|
$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) {
|
protected function run(): int
|
||||||
fwrite(STDERR, "Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]\n");
|
{
|
||||||
exit(1);
|
$path = $this->getArgument('--path');
|
||||||
}
|
$version = $this->getArgument('--version');
|
||||||
|
$branch = $this->getArgument('--branch');
|
||||||
|
$stability = $this->getArgument('--stability');
|
||||||
|
|
||||||
// Append stability suffix for non-stable releases
|
// Auto-detect branch from git or GitHub env
|
||||||
$stabilitySuffixMap = [
|
if ($branch === '') {
|
||||||
'stable' => '',
|
$branch = trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
|
||||||
'development' => '-dev',
|
if (empty($branch) || $branch === 'HEAD') {
|
||||||
'dev' => '-dev',
|
$branch = getenv('GITHUB_REF_NAME') ?: 'main';
|
||||||
'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";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($updated !== $content) {
|
if ($version === '') {
|
||||||
file_put_contents($file, $updated);
|
$this->log('ERROR', 'Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]');
|
||||||
echo "Dolibarr: " . basename($file) . " → version={$version}, branch={$branch}\n";
|
return 1;
|
||||||
$changed++;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Joomla: <version> in XML manifests (top-level + sub-packages)
|
// Strip any existing suffix(es) before applying the correct one
|
||||||
if (in_array($platform, ['waas-component', 'joomla'], true)) {
|
$version = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
|
||||||
$xmlFiles = array_merge(
|
|
||||||
glob("{$root}/src/*.xml") ?: [],
|
// Append stability suffix for non-stable releases
|
||||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
$stabilitySuffixMap = [
|
||||||
glob("{$root}/*.xml") ?: []
|
'stable' => '',
|
||||||
);
|
'development' => '-dev',
|
||||||
if (empty($xmlFiles)) {
|
'dev' => '-dev',
|
||||||
$xmlFiles = glob("{$root}/*.xml") ?: [];
|
'alpha' => '-alpha',
|
||||||
}
|
'beta' => '-beta',
|
||||||
foreach ($xmlFiles as $file) {
|
'rc' => '-rc',
|
||||||
$content = file_get_contents($file);
|
'release-candidate' => '-rc',
|
||||||
if (!str_contains($content, '<extension')) continue;
|
];
|
||||||
$updated = preg_replace(
|
$suffix = $stabilitySuffixMap[$stability] ?? '';
|
||||||
'|<version>[^<]*</version>|',
|
if ($suffix !== '' && !str_ends_with($version, $suffix)) {
|
||||||
"<version>{$version}</version>",
|
$version .= $suffix;
|
||||||
$content
|
echo "Version with stability suffix: {$version}\n";
|
||||||
);
|
|
||||||
if ($updated !== $content) {
|
|
||||||
file_put_contents($file, $updated);
|
|
||||||
$relPath = str_replace($root . '/', '', $file);
|
|
||||||
echo "Joomla: {$relPath} → {$version}\n";
|
|
||||||
$changed++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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') {
|
||||||
|
$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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
$xmlFiles = array_merge(
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
if (empty($xmlFiles)) {
|
||||||
|
$xmlFiles = glob("{$root}/*.xml") ?: [];
|
||||||
|
}
|
||||||
|
foreach ($xmlFiles as $file) {
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
if (!str_contains($content, '<extension')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$updated = preg_replace(
|
||||||
|
'|<version>[^<]*</version>|',
|
||||||
|
"<version>{$version}</version>",
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($updated !== null && $updated !== $content) {
|
||||||
|
file_put_contents($file, $updated);
|
||||||
|
$relPath = str_replace($root . '/', '', $file);
|
||||||
|
echo "Joomla: {$relPath} → {$version}\n";
|
||||||
|
$changed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
$app = new VersionSetPlatformCli();
|
||||||
if (empty($platform)) {
|
exit($app->execute());
|
||||||
echo "No .mokostandards file — skipping platform version set\n";
|
|
||||||
} else {
|
|
||||||
echo "No platform-specific version files found for {$platform}\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exit(0);
|
|
||||||
|
|||||||
@@ -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.24.00
|
||||||
|
* 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());
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "mokoconsulting-tech/enterprise",
|
"name": "mokoconsulting-tech/enterprise",
|
||||||
"description": "MokoStandards Enterprise API \u2014 PHP implementation",
|
"description": "moko-platform Enterprise API \u2014 PHP implementation",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"version": "09.00.00",
|
"version": "09.23.00",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"authors": [
|
"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
File diff suppressed because it is too large
Load Diff
@@ -1,686 +0,0 @@
|
|||||||
/**
|
|
||||||
* Default Repository Structure Definition
|
|
||||||
* Default repository structure applicable to all repository types with minimal requirements
|
|
||||||
*
|
|
||||||
* 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 = "Default Repository Structure"
|
|
||||||
description = "Default repository structure applicable to all repository types with minimal requirements"
|
|
||||||
repository_type = "library"
|
|
||||||
platform = "multi-platform"
|
|
||||||
last_updated = "2026-01-16T00:00:00Z"
|
|
||||||
maintainer = "Moko Consulting"
|
|
||||||
version = "05.00.00"
|
|
||||||
schema_version = "1.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
root_files = [
|
|
||||||
{
|
|
||||||
name = "README.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Project overview and documentation"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-README.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "README.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/required/template-README.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "LICENSE"
|
|
||||||
extension = ""
|
|
||||||
description = "License file (GPL-3.0-or-later)"
|
|
||||||
requirement_status = "required"
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/licenses"
|
|
||||||
source_filename = "GPL-3.0"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "LICENSE"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/licenses/GPL-3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CHANGELOG.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Version history and changes"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-CHANGELOG.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "CHANGELOG.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/required/template-CHANGELOG.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CONTRIBUTING.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Contribution guidelines"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "contributor"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-CONTRIBUTING.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "CONTRIBUTING.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/required/template-CONTRIBUTING.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "SECURITY.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Security policy and vulnerability reporting"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-SECURITY.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "SECURITY.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/required/template-SECURITY.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CODE_OF_CONDUCT.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Community code of conduct"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "contributor"
|
|
||||||
source_path = "templates/docs/extra"
|
|
||||||
source_filename = "template-CODE_OF_CONDUCT.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "CODE_OF_CONDUCT.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/extra/template-CODE_OF_CONDUCT.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "ROADMAP.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Project roadmap with version goals and milestones"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/extra"
|
|
||||||
source_filename = "template-ROADMAP.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "ROADMAP.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/extra/template-ROADMAP.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "GOVERNANCE.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Project governance model and decision-making process"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/extra"
|
|
||||||
source_filename = "template-GOVERNANCE.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "GOVERNANCE.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/extra/template-GOVERNANCE.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".gitignore"
|
|
||||||
extension = "gitignore"
|
|
||||||
description = "Git ignore patterns"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".gitattributes"
|
|
||||||
extension = "gitattributes"
|
|
||||||
description = "Git attributes configuration"
|
|
||||||
requirement_status = "required"
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".editorconfig"
|
|
||||||
extension = "editorconfig"
|
|
||||||
description = "Editor configuration for consistent coding style"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "Makefile"
|
|
||||||
description = "Build automation"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
audience = "developer"
|
|
||||||
source_path = "templates/makefiles"
|
|
||||||
source_filename = "Makefile.generic.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "Makefile"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/makefiles/Makefile.generic.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "composer.json"
|
|
||||||
extension = "json"
|
|
||||||
description = "Composer manifest — requires mokoconsulting-tech/enterprise for CLI scripts and tooling"
|
|
||||||
required = true
|
|
||||||
always_overwrite = false
|
|
||||||
audience = "developer"
|
|
||||||
template = "templates/configs/composer.generic.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "renovate.json"
|
|
||||||
extension = "json"
|
|
||||||
description = "Renovate dependency management configuration"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
audience = "developer"
|
|
||||||
template = "templates/configs/renovate.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
directories = [
|
|
||||||
{
|
|
||||||
name = "docs"
|
|
||||||
path = "docs"
|
|
||||||
description = "Documentation directory"
|
|
||||||
requirement_status = "required"
|
|
||||||
purpose = "Contains comprehensive project documentation"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "index.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Documentation index"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
template = "templates/docs/index.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "INSTALLATION.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Installation and setup instructions"
|
|
||||||
requirement_status = "required"
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-INSTALLATION.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "docs"
|
|
||||||
destination_filename = "INSTALLATION.md"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/docs/required/template-INSTALLATION.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "API.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "API documentation"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "ARCHITECTURE.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Architecture documentation"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "scripts"
|
|
||||||
path = "scripts"
|
|
||||||
description = "Repo-specific scripts — not managed by MokoStandards sync"
|
|
||||||
required = false
|
|
||||||
purpose = "Optional directory for repo-specific build helpers and one-off scripts. MokoStandards tools are installed via Composer (mokoconsulting-tech/enterprise) and called through vendor/bin/."
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "MokoStandards.override.xml"
|
|
||||||
extension = "xml"
|
|
||||||
description = "MokoStandards sync override configuration"
|
|
||||||
requirement_status = "optional"
|
|
||||||
always_overwrite = false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "src"
|
|
||||||
path = "src"
|
|
||||||
description = "Source code directory"
|
|
||||||
requirement_status = "required"
|
|
||||||
purpose = "Contains application source code"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "tests"
|
|
||||||
path = "tests"
|
|
||||||
description = "Test files"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
purpose = "Contains unit tests, integration tests, and test fixtures"
|
|
||||||
subdirectories = [
|
|
||||||
{
|
|
||||||
name = "unit"
|
|
||||||
path = "tests/unit"
|
|
||||||
description = "Unit tests"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "integration"
|
|
||||||
path = "tests/integration"
|
|
||||||
description = "Integration tests"
|
|
||||||
requirement_status = "optional"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".github"
|
|
||||||
path = ".github"
|
|
||||||
description = "GitHub-specific configuration"
|
|
||||||
requirement_status = "required"
|
|
||||||
purpose = "Contains GitHub Actions workflows and configuration"
|
|
||||||
subdirectories = [
|
|
||||||
{
|
|
||||||
name = "workflows"
|
|
||||||
path = ".github/workflows"
|
|
||||||
description = "GitHub Actions workflows"
|
|
||||||
requirement_status = "required"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "test.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Comprehensive testing workflow"
|
|
||||||
requirement_status = "optional"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = "templates/workflows/generic"
|
|
||||||
source_filename = "test.yml.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "test.yml"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/workflows/generic/test.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "code-quality.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Code quality and linting workflow"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = "templates/workflows/generic"
|
|
||||||
source_filename = "code-quality.yml.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "code-quality.yml"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/workflows/generic/code-quality.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "codeql-analysis.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "CodeQL security analysis workflow"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = "templates/workflows/generic"
|
|
||||||
source_filename = "codeql-analysis.yml.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "codeql-analysis.yml"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/workflows/generic/codeql-analysis.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Deployment workflow"
|
|
||||||
requirement_status = "optional"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = "templates/workflows/generic"
|
|
||||||
source_filename = "deploy.yml.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "deploy.yml"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/workflows/generic/deploy.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "release-cycle.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Release management workflow with automated release flow"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = ".github/workflows"
|
|
||||||
source_filename = "release-cycle.yml"
|
|
||||||
source_type = "copy"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "release-cycle.yml"
|
|
||||||
create_path = true
|
|
||||||
template = ".github/workflows/release-cycle.yml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "standards-compliance.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "MokoStandards compliance validation"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = ".github/workflows"
|
|
||||||
source_filename = "standards-compliance.yml"
|
|
||||||
source_type = "copy"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "standards-compliance.yml"
|
|
||||||
create_path = true
|
|
||||||
template = ".github/workflows/standards-compliance.yml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "enterprise-firewall-setup.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Enterprise firewall configuration for trusted domain access"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/enterprise-firewall-setup.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-dev.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment of src/ to the development server"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/deploy-dev.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-demo.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment of src/ to the demo server on merge to main"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/deploy-demo.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-rs.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment of src/ to the release staging server on merge to main"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/deploy-rs.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "sync-version-on-merge.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Auto-bump patch version on merge and propagate to all file headers"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/sync-version-on-merge.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "auto-release.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Auto-create GitHub Release on push to main with version from README.md"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/auto-release.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "repository-cleanup.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Scheduled cleanup: delete retired workflows, stale branches, old workflow runs"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/repository-cleanup.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "auto-dev-issue.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Auto-create tracking issue when a dev/** branch is pushed"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/auto-dev-issue.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "cascade-dev.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Forward-merge main to all open branches (dev, rc/*, beta/*, alpha/*) on push to main"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "workflows/cascade-dev.yml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "gitleaks.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Secret scanning — detect leaked credentials, API keys, and tokens using Gitleaks"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "workflows/gitleaks.yml"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "ISSUE_TEMPLATE"
|
|
||||||
path = ".github/ISSUE_TEMPLATE"
|
|
||||||
description = "GitHub issue templates synced from MokoStandards"
|
|
||||||
requirement_status = "required"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "config.yml"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/config.yml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "adr.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/adr.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "bug_report.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/bug_report.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "documentation.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/documentation.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "enterprise_support.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/enterprise_support.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "feature_request.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/feature_request.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "firewall-request.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/firewall-request.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "question.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/question.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "request-license.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/request-license.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "rfc.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/rfc.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "security.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/security.md"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "node_modules"
|
|
||||||
path = "node_modules"
|
|
||||||
description = "Node.js dependencies (generated)"
|
|
||||||
requirement_status = "not-allowed"
|
|
||||||
purpose = "Generated directory that should not be committed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "vendor"
|
|
||||||
path = "vendor"
|
|
||||||
description = "PHP dependencies (generated)"
|
|
||||||
requirement_status = "not-allowed"
|
|
||||||
purpose = "Generated directory that should not be committed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "build"
|
|
||||||
path = "build"
|
|
||||||
description = "Build artifacts (generated)"
|
|
||||||
requirement_status = "not-allowed"
|
|
||||||
purpose = "Generated directory that should not be committed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "dist"
|
|
||||||
path = "dist"
|
|
||||||
description = "Distribution files (generated)"
|
|
||||||
requirement_status = "not-allowed"
|
|
||||||
purpose = "Generated directory that should not be committed"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
repository_requirements = {
|
|
||||||
secrets = [
|
|
||||||
{
|
|
||||||
name = "GH_TOKEN"
|
|
||||||
description = "Org-level GitHub PAT — configure in org Actions secrets"
|
|
||||||
required = true
|
|
||||||
scope = "organisation"
|
|
||||||
used_in = "GitHub Actions workflows"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CODECOV_TOKEN"
|
|
||||||
description = "Codecov upload token for code coverage reporting"
|
|
||||||
required = false
|
|
||||||
scope = "repository"
|
|
||||||
used_in = "CI workflow code coverage step"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
variables = [
|
|
||||||
{
|
|
||||||
name = "NODE_VERSION"
|
|
||||||
description = "Node.js version for CI/CD"
|
|
||||||
default_value = "18"
|
|
||||||
required = false
|
|
||||||
scope = "repository"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "PYTHON_VERSION"
|
|
||||||
description = "Python version for CI/CD"
|
|
||||||
default_value = "3.9"
|
|
||||||
required = false
|
|
||||||
scope = "repository"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
branch_protections = [
|
|
||||||
{
|
|
||||||
branch_pattern = "main"
|
|
||||||
require_pull_request = true
|
|
||||||
required_approvals = 0
|
|
||||||
dismiss_stale_reviews = true
|
|
||||||
block_on_rejected_reviews = true
|
|
||||||
restrict_pushes = true
|
|
||||||
push_whitelist = ["jmiller"]
|
|
||||||
enable_force_push = true
|
|
||||||
force_push_whitelist = ["jmiller"]
|
|
||||||
enforce_admins = false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
branch_pattern = "dev"
|
|
||||||
require_pull_request = false
|
|
||||||
required_approvals = 0
|
|
||||||
restrict_pushes = false
|
|
||||||
enable_force_push = true
|
|
||||||
force_push_whitelist = ["jmiller"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
branch_pattern = "rc/*"
|
|
||||||
require_pull_request = false
|
|
||||||
required_approvals = 0
|
|
||||||
restrict_pushes = false
|
|
||||||
enable_force_push = true
|
|
||||||
force_push_whitelist = ["jmiller"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
branch_pattern = "beta/*"
|
|
||||||
require_pull_request = false
|
|
||||||
required_approvals = 0
|
|
||||||
restrict_pushes = false
|
|
||||||
enable_force_push = true
|
|
||||||
force_push_whitelist = ["jmiller"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
branch_pattern = "alpha/*"
|
|
||||||
require_pull_request = false
|
|
||||||
required_approvals = 0
|
|
||||||
restrict_pushes = false
|
|
||||||
enable_force_push = true
|
|
||||||
force_push_whitelist = ["jmiller"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
repository_settings = {
|
|
||||||
has_issues = true
|
|
||||||
has_projects = true
|
|
||||||
has_wiki = false
|
|
||||||
has_discussions = false
|
|
||||||
allow_merge_commit = true
|
|
||||||
allow_squash_merge = true
|
|
||||||
allow_rebase_merge = false
|
|
||||||
delete_branch_on_merge = true
|
|
||||||
allow_auto_merge = false
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [
|
|
||||||
{
|
|
||||||
name = "bug"
|
|
||||||
color = "d73a4a"
|
|
||||||
description = "Something isn't working"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "enhancement"
|
|
||||||
color = "a2eeef"
|
|
||||||
description = "New feature or request"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "documentation"
|
|
||||||
color = "0075ca"
|
|
||||||
description = "Improvements or additions to documentation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "security"
|
|
||||||
color = "ee0701"
|
|
||||||
description = "Security vulnerability or concern"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
/**
|
|
||||||
* .github-private Repository Structure Definition
|
|
||||||
* Org-level private repository containing universal GitHub Actions workflows,
|
|
||||||
* helper scripts, and default issue templates for all governed repositories.
|
|
||||||
*
|
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
* Schema Version: 1.0
|
|
||||||
*
|
|
||||||
* NOTES
|
|
||||||
* ─────
|
|
||||||
* • GitHub reads ISSUE_TEMPLATE/ from this repo as org-wide defaults for any
|
|
||||||
* governed repo that does not supply its own templates.
|
|
||||||
* • Workflows in .github/workflows/ support both standalone execution and
|
|
||||||
* workflow_call so governed repos can invoke them as reusable workflows via
|
|
||||||
* `uses: mokoconsulting-tech/.github-private/.github/workflows/<name>.yml@main`.
|
|
||||||
* • This repo is EXCLUDED from bulk-repo-sync — it manages its own content
|
|
||||||
* independently as GitHub's org-level defaults repo.
|
|
||||||
*/
|
|
||||||
|
|
||||||
locals {
|
|
||||||
github_private_repository_structure = {
|
|
||||||
metadata = {
|
|
||||||
name = ".github-private"
|
|
||||||
description = "Private GitHub org defaults — universal workflows, issue templates, and helper scripts"
|
|
||||||
repository_type = "github-private"
|
|
||||||
platform = "github-private"
|
|
||||||
last_updated = "2026-03-12T00:00:00Z"
|
|
||||||
maintainer = "Moko Consulting"
|
|
||||||
version = "05.00.00"
|
|
||||||
schema_version = "1.0"
|
|
||||||
visibility = "private"
|
|
||||||
sync_priority = -1
|
|
||||||
exclude_from_sync = true
|
|
||||||
}
|
|
||||||
|
|
||||||
root_files = [
|
|
||||||
{
|
|
||||||
name = "README.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Repository overview — purpose, contents, and how governed repos use this repo"
|
|
||||||
required = true
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "LICENSE"
|
|
||||||
extension = ""
|
|
||||||
description = "License file (GPL-3.0-or-later)"
|
|
||||||
required = true
|
|
||||||
audience = "general"
|
|
||||||
template = "templates/licenses/GPL-3.0"
|
|
||||||
license_type = "GPL-3.0-or-later"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CHANGELOG.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Version history and changes"
|
|
||||||
required = true
|
|
||||||
audience = "general"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "SECURITY.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Security policy and private vulnerability reporting"
|
|
||||||
required = true
|
|
||||||
audience = "general"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CODE_OF_CONDUCT.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Community code of conduct"
|
|
||||||
required = true
|
|
||||||
always_overwrite = true
|
|
||||||
audience = "contributor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CONTRIBUTING.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Contribution guidelines"
|
|
||||||
required = true
|
|
||||||
audience = "contributor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "GOVERNANCE.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Governance policy and decision-making process"
|
|
||||||
required = true
|
|
||||||
always_overwrite = true
|
|
||||||
audience = "general"
|
|
||||||
template = "templates/docs/required/GOVERNANCE.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".gitignore"
|
|
||||||
extension = "gitignore"
|
|
||||||
description = "Git ignore patterns"
|
|
||||||
required = true
|
|
||||||
always_overwrite = false
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".gitattributes"
|
|
||||||
extension = "gitattributes"
|
|
||||||
description = "Git attributes configuration"
|
|
||||||
required = true
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".editorconfig"
|
|
||||||
extension = "editorconfig"
|
|
||||||
description = "Editor configuration for consistent coding style"
|
|
||||||
required = true
|
|
||||||
always_overwrite = false
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".gitea/.mokostandards"
|
|
||||||
extension = "xml"
|
|
||||||
description = "MokoStandards XML manifest — generated programmatically by RepositorySynchronizer::migrateMokoStandards()"
|
|
||||||
required = true
|
|
||||||
always_overwrite = false
|
|
||||||
template = "managed-by-sync"
|
|
||||||
source_type = "programmatic"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
directories = [
|
|
||||||
{
|
|
||||||
name = "ISSUE_TEMPLATE"
|
|
||||||
path = "ISSUE_TEMPLATE"
|
|
||||||
description = "Org-default issue templates — applied to all governed repos without their own templates"
|
|
||||||
requirement_status = "required"
|
|
||||||
purpose = "GitHub reads ISSUE_TEMPLATE/ from this repo as org-wide defaults"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "config.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Issue template chooser — disables blank issues and lists contact links"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github-private/ISSUE_TEMPLATE/config.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "bug_report.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Bug report issue template"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
template = "templates/github-private/ISSUE_TEMPLATE/bug_report.md.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "feature_request.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Feature request issue template"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
template = "templates/github-private/ISSUE_TEMPLATE/feature_request.md.template"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "scripts"
|
|
||||||
path = "scripts"
|
|
||||||
description = "Helper scripts used by universal workflows and available as git hooks"
|
|
||||||
requirement_status = "required"
|
|
||||||
purpose = "Reusable Bash utilities for commit-message and PR-title validation"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "check-pr-title.sh"
|
|
||||||
extension = "sh"
|
|
||||||
description = "Validates PR title follows conventional-commit format"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github-private/scripts/check-pr-title.sh.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "check-commit-msg.sh"
|
|
||||||
extension = "sh"
|
|
||||||
description = "Validates individual commit messages follow conventional-commit format; usable as a git commit-msg hook"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github-private/scripts/check-commit-msg.sh.template"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".github"
|
|
||||||
path = ".github"
|
|
||||||
description = "GitHub-specific configuration for .github-private itself"
|
|
||||||
requirement_status = "required"
|
|
||||||
purpose = "Contains CI workflows for this repo and reusable workflows callable org-wide"
|
|
||||||
subdirectories = [
|
|
||||||
{
|
|
||||||
name = "workflows"
|
|
||||||
path = ".github/workflows"
|
|
||||||
description = "CI + universal reusable workflows; callable via uses: mokoconsulting-tech/.github-private/.github/workflows/<name>.yml@main"
|
|
||||||
requirement_status = "required"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "stale.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Marks stale issues and pull requests; standalone (schedule) and reusable (workflow_call)"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github-private/workflows/stale.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "auto-assign.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Auto-assigns PR author and logs CODEOWNERS status; standalone and reusable"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github-private/workflows/auto-assign.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "pr-labeler.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Labels PRs from branch name and validates PR title format; standalone and reusable"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github-private/workflows/pr-labeler.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "welcome.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Posts welcome message on first-time contributor PRs and issues; standalone and reusable"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github-private/workflows/welcome.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "standards-compliance.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "MokoStandards compliance validation"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = ".github/workflows/standards-compliance.yml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-dev.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment of src/ to the development server"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/deploy-dev.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "sync-version-on-merge.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Auto-bump patch version on merge and propagate to all file headers"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/sync-version-on-merge.yml.template"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
repository_requirements = {
|
|
||||||
secrets = [
|
|
||||||
{
|
|
||||||
name = "GH_TOKEN"
|
|
||||||
description = "Org-level GitHub PAT for automation — required for bulk sync and workflow execution"
|
|
||||||
required = true
|
|
||||||
scope = "org"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "DEV_FTP_KEY"
|
|
||||||
description = "SSH private key for SFTP dev deployment (preferred); if DEV_FTP_PASSWORD is also set it is used as the key passphrase, with password-only as fallback"
|
|
||||||
required = false
|
|
||||||
scope = "org"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "DEV_FTP_PASSWORD"
|
|
||||||
description = "SFTP password for dev deployment; used as SSH key passphrase when DEV_FTP_KEY is also set, and as standalone fallback if key auth fails"
|
|
||||||
required = false
|
|
||||||
scope = "org"
|
|
||||||
note = "At least one of DEV_FTP_KEY or DEV_FTP_PASSWORD must be configured"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
variables = [
|
|
||||||
{
|
|
||||||
name = "DEV_FTP_HOST"
|
|
||||||
description = "Dev server hostname; may include port suffix (e.g. dev.example.com or dev.example.com:2222)"
|
|
||||||
required = true
|
|
||||||
scope = "org"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "DEV_FTP_PATH"
|
|
||||||
description = "Base remote path for SFTP deployment (e.g. /var/www/html)"
|
|
||||||
required = true
|
|
||||||
scope = "org"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "DEV_FTP_USERNAME"
|
|
||||||
description = "SFTP username for dev server authentication"
|
|
||||||
required = true
|
|
||||||
scope = "org"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "DEV_FTP_PORT"
|
|
||||||
description = "Explicit SFTP port override; if omitted the port is parsed from DEV_FTP_HOST or defaults to 22"
|
|
||||||
required = false
|
|
||||||
scope = "org"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "DEV_FTP_PATH_SUFFIX"
|
|
||||||
description = "Per-repo path suffix appended to DEV_FTP_PATH (e.g. /.github-private)"
|
|
||||||
required = false
|
|
||||||
scope = "repo"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
repository_settings = {
|
|
||||||
visibility = "private"
|
|
||||||
has_issues = true
|
|
||||||
has_projects = false
|
|
||||||
has_wiki = false
|
|
||||||
has_discussions = false
|
|
||||||
allow_squash_merge = true
|
|
||||||
allow_merge_commit = false
|
|
||||||
allow_rebase_merge = true
|
|
||||||
delete_branch_on_merge = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user