Compare commits
589 Commits
release-candidate
...
v09
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fe4f83e73 | |||
| 7e5c322792 | |||
| b010677d75 | |||
| 9275e581c2 | |||
| 3f3b1f79a0 | |||
| 83842c50ad | |||
| fbedd5966c | |||
| eca2c13018 | |||
| 48d000107d | |||
| 7ceb9528cc | |||
| 5fabaec477 | |||
| e40b799101 | |||
| 7e9784e723 | |||
| 209dee14fd | |||
| 81351f45fd | |||
| fd451b4b73 | |||
| d0dbd1dceb | |||
| 3e2e291819 | |||
| 5975ea38d8 | |||
| 8ad548f4a3 | |||
| cbb4d73df5 | |||
| 47cb47ebdb | |||
| 22b0f8af7e | |||
| 08ca1429ae | |||
| e8da1a30ff | |||
| fb754b1a07 | |||
| 9a2c164207 | |||
| 78c1329a83 | |||
| 05f43ed88f | |||
| 05e4f39e7d | |||
| 3dcb3b6d3a | |||
| db4e6f5c6b | |||
| aa7fc45a67 | |||
| 03fe66238f | |||
| a5ae616a94 | |||
| ff7924de7d | |||
| 1690e291d2 | |||
| 7f818809ef | |||
| 597b40f3f2 | |||
| 80108f9ca8 | |||
| b33623c731 | |||
| 9ff59ce405 | |||
| 9c6f393f92 | |||
| a418798a4d | |||
| baafffb1be | |||
| 1c930ca9bd | |||
| 3e37035786 | |||
| 5805358ef4 | |||
| 44c6bcbc2d | |||
| 78fcbdd4a9 | |||
| 4fd1acb68c | |||
| 9f7599fdb1 | |||
| 57a0b491ea | |||
| f76cd94c64 | |||
| ca1c3e0dba | |||
| 9ee50d0058 | |||
| bc67a53442 | |||
| 147cf663a6 | |||
| e41d9b9335 | |||
| 5c5c5e9ff2 | |||
| c53ab7e44c | |||
| 1b0d5bd2f3 | |||
| 7281f60ba0 | |||
| bfe345747d | |||
| 31c4b86d6e | |||
| b5bad37afc | |||
| ea2dcd7d96 | |||
| 989e84c44c | |||
| 2cfc0a61e9 | |||
| 11bd5e8f7f | |||
| cbfa23c4c4 | |||
| e1104eeebc | |||
| 968f85f622 | |||
| 5f7e6a9b1a | |||
| ded6563d2e | |||
| 5b7817f104 | |||
| fb916e857e | |||
| 81ced97bd6 | |||
| 79d3907004 | |||
| 5e8773a2c6 | |||
| 9a99bffc6b | |||
| bffb8c3f94 | |||
| bb0ee435e8 | |||
| c2804de1d7 | |||
| 251c1970f9 | |||
| 1b9ede4750 | |||
| bc47944d8f | |||
| bed5bb46df | |||
| 5f6fb9ec64 | |||
| bed73b083a | |||
| 4cdcf76301 | |||
| 54e113ff3c | |||
| c55da9d67d | |||
| e196d97d3f | |||
| 3456a15237 | |||
| ef1b5b6258 | |||
| 63b0baceed | |||
| 7bbd4853a8 | |||
| 2b48b09ffa | |||
| ac8c22f183 | |||
| b1d4a979f8 | |||
| d64fea05bf | |||
| 5297a2b188 | |||
| 240ae2f803 | |||
| 4cc3f5bee4 | |||
| a888b6c9c7 | |||
| bd2799c761 | |||
| e03b29983a | |||
| 4883d624f9 | |||
| e2cae35bca | |||
| f1f907bca0 | |||
| 315bb89836 | |||
| 73a90616dd | |||
| d723475931 | |||
| 0be956e56d | |||
| 8892ade56a | |||
| 77989fe413 | |||
| b8a282cdbc | |||
| 5ce1dca4f8 | |||
| 4f48dcae5c | |||
| 62f228f95c | |||
| c78c242024 | |||
| d9846b1c01 | |||
| f3ba340c46 | |||
| 14de7dbe19 | |||
| 464ebb1a25 | |||
| 05f04a0a31 | |||
| 492f1cbb80 | |||
| a4eed1c1bb | |||
| e618bedd2d | |||
| 008cdeb996 | |||
| 5f3b0d9980 | |||
| 3e20003d18 | |||
| 32ef515d51 | |||
| 0aa113652f | |||
| 41c5043352 | |||
| e5784c0e1d | |||
| ff3e4e323a | |||
| 05eb26c811 | |||
| 3f3e4e2aaa | |||
| 02d2e55954 | |||
| 9b456da7e5 | |||
| 60e53d5e6e | |||
| 12d27e70c2 | |||
| 3c11006aae | |||
| 4b9a682e53 | |||
| 89007ab9ba | |||
| 4dfbcf4fd2 | |||
| c6da98289b | |||
| b7a07d2e08 | |||
| 93b2e87c38 | |||
| 03801ff925 | |||
| 7f9c3f2ab0 | |||
| 5b9d258135 | |||
| 3d1af376a0 | |||
| ab51b15dfb | |||
| a706a2cc32 | |||
| d37b547e87 | |||
| c33106de1c | |||
| 46d9af0ff6 | |||
| 6a29fbd99e | |||
| f09a855f0b | |||
| 8095ea607b | |||
| a09d880c0a | |||
| a84683df11 | |||
| 14763e3c49 | |||
| e19ca4d7a9 | |||
| de2c17e851 | |||
| e73731aab5 | |||
| eb3e2af1ff | |||
| c7faf96108 | |||
| a27afb4619 | |||
| f2d1695ac3 | |||
| d45cfadc0f | |||
| cb4265f07c | |||
| 197fd690bf | |||
| 98b9ae910b | |||
| 9fdd68672f | |||
| 2574fa1e8e | |||
| 853247dc99 | |||
| 32860d7d18 | |||
| 8a0e3b7a97 | |||
| 550bbe82c7 | |||
| a3d56575a3 | |||
| 414276bcbd | |||
| f79152fd72 | |||
| 9ade6d258a | |||
| 0e00cafe48 | |||
| 7f7ad678c4 | |||
| 39bb17d053 | |||
| 6138510bd7 | |||
| 054d77dc43 | |||
| 851ffc224c | |||
| 3ece17543c | |||
| fd2c293932 | |||
| 0510f75680 | |||
| f35c423697 | |||
| d0b14631d6 | |||
| 9eba669788 | |||
| 9981819c30 | |||
| 2775e9e8ef | |||
| 495ebf7d3d | |||
| 2adc10a41a | |||
| 2072e2e55e | |||
| 1afe0c79f1 | |||
| 412a4066c0 | |||
| 64f2a2a185 | |||
| a90c427539 | |||
| 40c415404d | |||
| ddecb750ec | |||
| 78d41451f3 | |||
| 854d81eb6d | |||
| 743b28b088 | |||
| 1a5392db71 | |||
| 4215ffb949 | |||
| 1a8b9f5bd7 | |||
| 952ebaf1c7 | |||
| d37bdb6ac3 | |||
| e72ba9e12f | |||
| 78404e427f | |||
| 33a6e47848 | |||
| 31b594ebb7 | |||
| b672b9af0e | |||
| 2f573025cd | |||
| bf8336cde2 | |||
| a33867ed3d | |||
| 59a0b26272 | |||
| a5d75ffd04 | |||
| fedd6726d4 | |||
| 7bd4ede119 | |||
| 3ac3abc7d1 | |||
| e57ad2c1ba | |||
| 33d5dac060 | |||
| fb64c176fe | |||
| 75508e8e75 | |||
| 6b395323de | |||
| 5adf6e2c66 | |||
| 3427f9f989 | |||
| a4e417c667 | |||
| 57326e597c | |||
| 11fbcf70f9 | |||
| ec18b0e52f | |||
| 66f6fd5a05 | |||
| bcde27afc6 | |||
| 8d7b429b3a | |||
| 7ee0c52aa1 | |||
| de639305a1 | |||
| 219fef1691 | |||
| a5ced62ebe | |||
| 755e296c1d | |||
| 92293a0dee | |||
| 44f21f2c3c | |||
| 504d463ec2 | |||
| 3f689dc083 | |||
| 442cc2cc77 | |||
| 75f79fd0c8 | |||
| 66939d9cc5 | |||
| 857525268a | |||
| cf0b2726fd | |||
| 7d8c094227 | |||
| 56e53dff55 | |||
| d788400bfd | |||
| 96ffe19fe9 | |||
| e5b243ecf0 | |||
| c0a16f53ad | |||
| ac8af534f7 | |||
| 0962252de3 | |||
| e09444970a | |||
| 79f50df907 | |||
| e3c3e3b9cf | |||
| aea6ee49d4 | |||
| 8f8f180407 | |||
| 6252ebc9e1 | |||
| 79ee7c9fb9 | |||
| 98b5d6dc1a | |||
| aeb5aeb14c | |||
| 695c687d30 | |||
| 0ccca11029 | |||
| 4d2eb7f0bb | |||
| 4b5c778c0c | |||
| 853db948c6 | |||
| 4460259425 | |||
| f743071f2f | |||
| 9e1793dcc4 | |||
| 62bc1231b5 | |||
| b1cab83594 | |||
| 4f8dad938c | |||
| c214a11081 | |||
| d97fb4304a | |||
| 8c2c8197f7 | |||
| 36423595fb | |||
| 21b24e1f69 | |||
| 6975111c09 | |||
| c7bf015ef7 | |||
| 7ae8e4543a | |||
| a5dc9e7697 | |||
| af70ec5de2 | |||
| f766f264ff | |||
| 024fa7255b | |||
| c6db1aa5fc | |||
| 63860699b5 | |||
| c13945b961 | |||
| 725e16a12e | |||
| 8d51cce315 | |||
| e4e3b57fa2 | |||
| f3276d4082 | |||
| ae3ee70396 | |||
| ffb70de26e | |||
| 29c8dcc66c | |||
| e26e1ba7f9 | |||
| bb8c3dc0f7 | |||
| a5b804f796 | |||
| e01b6945c8 | |||
| 26eaa1e60d | |||
| 4d16fbeace | |||
| cefe1ea00a | |||
| 8d39a7e927 | |||
| cf63cbdeba | |||
| fce2e79cab | |||
| ba2783e01e | |||
| 09db381e5b | |||
| 3be806d5af | |||
| 4bc7c16e56 | |||
| 99f0e536a9 | |||
| c905fa601b | |||
| dc2d0b027e | |||
| f3cb93eb65 | |||
| b46706a7a0 | |||
| 6de15810f4 | |||
| 4ec51e262b | |||
| a365c9ec24 | |||
| 4246cb2938 | |||
| f2a76c0ae0 | |||
| 8d689cf2bb | |||
| 427e058d9c | |||
| 40ff994a1a | |||
| cdf0efa6c4 | |||
| ee1c7f1b4b | |||
| 20d4cb0434 | |||
| e78125c931 | |||
| 0b13722a77 | |||
| 4fbc38c40f | |||
| f24df6cd76 | |||
| 436e9ec872 | |||
| 19907ee7cb | |||
| 4a0326615b | |||
| 149b2f9167 | |||
| e4265045eb | |||
| 55ebff78db | |||
| 592cbd539f | |||
| 7a98953674 | |||
| 0962f50b81 | |||
| 77858e5cdf | |||
| 96d92d4733 | |||
| 2677298d81 | |||
| 9c552748dc | |||
| 8e5142b674 | |||
| e3ddcfc70d | |||
| 431b673922 | |||
| 94f4dc482f | |||
| b17e1fe1ce | |||
| a5bee8d5a3 | |||
| cb0ed9b66b | |||
| d56505a478 | |||
| bb10ba2f91 | |||
| 4a07d82410 | |||
| e33bceb7ee | |||
| f126ac66bc | |||
| 36ba47ac0b | |||
| 0521ee54b2 | |||
| 2db218d320 | |||
| 8c5f74a44e | |||
| a575604998 | |||
| 7d345d7c74 | |||
| 62f7d798d2 | |||
| fe4f5f2425 | |||
| 0a7f558a1a | |||
| 368c690f5e | |||
| 306472f6a1 | |||
| e4a5f56ca8 | |||
| 17b09fca27 | |||
| 64de7159cf | |||
| 9ece74e3e9 | |||
| 89c63f0dd0 | |||
| 7a1ffcc306 | |||
| 8cac5140d1 | |||
| 0e170b6ee5 | |||
| 709340c519 | |||
| dcbfb4cf0c | |||
| 4aaf88c26e | |||
| de9c36b9d1 | |||
| ec6cd62e3d | |||
| 17c51d257e | |||
| dca040ae6e | |||
| 2682af6a54 | |||
| 40d2786ccd | |||
| 1c85296c1e | |||
| 22864806af | |||
| 8c5e0702d0 | |||
| 08bb7f5ef5 | |||
| 4de1f24ab1 | |||
| 574460a102 | |||
| e6e01fa5a8 | |||
| 9f8bb4a467 | |||
| 6b7edbe4bb | |||
| db2aa274ba | |||
| 1c563ac57e | |||
| ea17e36467 | |||
| ba2fecfc5d | |||
| f27a092d94 | |||
| 8d03190c26 | |||
| 03ac6417ca | |||
| f8063cde8a | |||
| 9ea456e200 | |||
| 05ddd8c3f6 | |||
| 0efc36c01e | |||
| ed7bb4d0cd | |||
| 8d71491127 | |||
| 3beb9afb6d | |||
| f0f793102e | |||
| ec5b53cc13 | |||
| 1fad2ea277 | |||
| ccbd7e0709 | |||
| 81b3c66a24 | |||
| b086fd86e7 | |||
| 48feb11c34 | |||
| 931ac0ee86 | |||
| b64bbe6c71 | |||
| 5c02b52c96 | |||
| 118bc058f6 | |||
| 95f1d05253 | |||
| b5d65e8aa3 | |||
| 4ecb70cdc6 | |||
| aab24bb3ee | |||
| c18005da93 | |||
| fac567ddc8 | |||
| 177b9a022f | |||
| ef15c2a0a5 | |||
| 8a5c1eea55 | |||
| deed8f2844 | |||
| 2602a0426c | |||
| 77715142fc | |||
| d89fb2904d | |||
| fd98406067 | |||
| d954b2373e | |||
| 48943328aa | |||
| 666e11e8dd | |||
| a14002c327 | |||
| 624e64499a | |||
| 61121252ca | |||
| 5dbb5aebcb | |||
| 0ff2ae6f84 | |||
| ede5ae90a5 | |||
| edf09ac744 | |||
| 63ae3bc9b0 | |||
| ebb2f5891a | |||
| 2c7478a6f6 | |||
| 63a21fe0c0 | |||
| 787d778e91 | |||
| 1c7de961c6 | |||
| c061338428 | |||
| 2e57e60335 | |||
| 87c7a7d3da | |||
| 0e273dae96 | |||
| 1799401db5 | |||
| 1d87be7d5e | |||
| 38a975ee57 | |||
| 34aace2638 | |||
| 993f77d5a8 | |||
| d318d5e854 | |||
| c511847fef | |||
| aa1800e2f6 | |||
| 15de3eed96 | |||
| f5d35e10d9 | |||
| 8245b5fd10 | |||
| dd6eb8fc24 | |||
| 8abc30835c | |||
| aeb574980b | |||
| de1fef1de6 | |||
| 61d3f16675 | |||
| 5e894a2c8f | |||
| 395921282e | |||
| 0e82802f0c | |||
| 2dc43603da | |||
| 38c2536c7b | |||
| 4e17ccf1f7 | |||
| 5d6d9536a2 | |||
| 5520aecc6f | |||
| 671027c4f4 | |||
| a6a9b8920e | |||
| 57d05a74bd | |||
| bcfd1fb029 | |||
| 89eec33c7b | |||
| 3f002a5996 | |||
| 46a87d2a98 | |||
| fe38765d03 | |||
| 3740c553da | |||
| 06b1a36320 | |||
| 2bfbc2d89d | |||
| c332c3ae5c | |||
| f368f271b7 | |||
| a4fbcc0f87 | |||
| 0119834cef | |||
| abc08fb6f2 | |||
| a9c1cd3c16 | |||
| 86ccfdc64f | |||
| c9735396a9 | |||
| 7525486710 | |||
| 1472dcb650 | |||
| 93fe181e1b | |||
| 8a864a2eb4 | |||
| 91fdd63fe9 | |||
| efc7180b01 | |||
| f51b3a97d9 | |||
| cbbb4895bb | |||
| f04d57a416 | |||
| b4b7947658 | |||
| f12f660641 | |||
| 8758570216 | |||
| 62394838b5 | |||
| 65e3c6acb6 | |||
| f71c186e26 | |||
| 7800eadbd7 | |||
| df81c55084 | |||
| 5548eae35d | |||
| 531e462d9d | |||
| 78c484c6a7 | |||
| ff07d0a563 | |||
| 5c0cb98082 | |||
| 6795b72fec | |||
| c3c427df14 | |||
| 11dc2206b7 | |||
| 2a84875a4e | |||
| cc9c648696 | |||
| 5db19b1201 | |||
| bcec65d285 | |||
| 2e97c97006 | |||
| 764451d003 | |||
| 4c9bb73765 | |||
| 57539c7592 | |||
| e7ac5f2c0b | |||
| 2f4420ce8b | |||
| 1311cacd2c | |||
| 6fce7e6569 | |||
| 7f5aa2f7f4 | |||
| 4d5d7edee5 | |||
| 94da1e3a51 | |||
| f850377f99 | |||
| e40de18dbb | |||
| c244790e44 | |||
| 327ffc7032 | |||
| d736df870a | |||
| 3e15d4d3b0 | |||
| 87ba8bc1c7 | |||
| 8c3eb17922 | |||
| c78cd167ea | |||
| 14c4408e8d | |||
| ae0d233b93 | |||
| c3e989d150 | |||
| d146b5d51e | |||
| 4cf967f92b | |||
| 4d99ab9a4e | |||
| 617344c4d7 | |||
| b57de90cef | |||
| dbd7ec8ae6 | |||
| f30c0dc9f9 | |||
| dcd22dcfdc | |||
| adcbd2d2f4 | |||
| 14b4477ff2 | |||
| 032c32637f | |||
| 16a86a94b7 | |||
| b68a23622a | |||
| 005ae12598 | |||
| 3834781899 | |||
| c00a04087f | |||
| 2b9bfb032e | |||
| b9109c51bc | |||
| 0f9f110c2d | |||
| 4cf931e7a3 | |||
| a7f758f888 | |||
| 7b863f690d | |||
| bd53fe834f | |||
| 784f423973 | |||
| 4742dfcbec | |||
| 5dff3346f0 | |||
| 029033c2f6 | |||
| 700e0abaac | |||
| bbadbfd2ad | |||
| c3fe454eb6 |
@@ -265,6 +265,11 @@ venv/
|
||||
*.coverage
|
||||
hypothesis/
|
||||
|
||||
# ============================================================
|
||||
# Local wiki clone (not version controlled)
|
||||
# ============================================================
|
||||
wiki/
|
||||
|
||||
# ============================================================
|
||||
# Dolibarr (base + runtime)
|
||||
# ============================================================
|
||||
@@ -682,6 +687,7 @@ modulebuilder.txt
|
||||
!/bin/moko
|
||||
/cache/*
|
||||
/cli/*
|
||||
!/cli/*.php
|
||||
/components/com_ajax/*
|
||||
/components/com_banners/*
|
||||
/components/com_config/*
|
||||
@@ -1062,3 +1068,5 @@ terraform.rc
|
||||
# but can be ignored if you want flexibility across different platforms
|
||||
# !.terraform.lock.hcl
|
||||
logs/validation/*.md
|
||||
profile.ps1
|
||||
.mcp.json
|
||||
|
||||
@@ -7,8 +7,8 @@ contact_links:
|
||||
- name: 💬 Ask a Question
|
||||
url: https://mokoconsulting.tech/
|
||||
about: Get help or ask questions through our website
|
||||
- name: 📚 MokoStandards Documentation
|
||||
url: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards
|
||||
- name: 📚 moko-platform Documentation
|
||||
url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
about: View our coding standards and best practices
|
||||
- name: 🔒 Report a Security Vulnerability
|
||||
url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new
|
||||
+1
-1
@@ -37,7 +37,7 @@ If you have ideas about how this could be implemented, share them here:
|
||||
Add any other context, mockups, or screenshots about the feature request here.
|
||||
|
||||
## Relevant Standards
|
||||
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
|
||||
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
|
||||
- [ ] Accessibility (WCAG 2.1 AA)
|
||||
- [ ] Localization (en_US/en_GB)
|
||||
- [ ] Security best practices
|
||||
@@ -3,7 +3,7 @@ name: Question
|
||||
about: Ask a question about usage, features, or best practices
|
||||
title: '[QUESTION] '
|
||||
labels: ['question']
|
||||
assignees: ['jmiller-moko']
|
||||
assignees: ['jmiller']
|
||||
---
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Use this template only for:
|
||||
<!-- Describe how this could be addressed -->
|
||||
|
||||
## Standards Reference
|
||||
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
|
||||
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
|
||||
- [ ] SPDX license identifiers
|
||||
- [ ] Secret management
|
||||
- [ ] Dependency security
|
||||
@@ -3,7 +3,7 @@ name: Version Bump
|
||||
about: Request or track a version change
|
||||
title: '[VERSION] '
|
||||
labels: 'version, type: version'
|
||||
assignees: 'jmiller-moko'
|
||||
assignees: 'jmiller'
|
||||
---
|
||||
|
||||
## Version Change
|
||||
@@ -0,0 +1,246 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/branch-protection.yml
|
||||
# BRIEF: Apply standardised branch protection rules to all governed repositories
|
||||
#
|
||||
# +========================================================================+
|
||||
# | BRANCH PROTECTION SETUP |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Applies protection rules for: main, dev, rc/*, beta/*, alpha/* |
|
||||
# | |
|
||||
# | main — Require PR, block rejected reviews, no force push |
|
||||
# | dev — Allow push, no force push, no delete |
|
||||
# | rc/* — Allow push, no force push, no delete |
|
||||
# | beta/* — Allow push, no force push, no delete |
|
||||
# | alpha/* — Allow push, no force push, no delete |
|
||||
# | |
|
||||
# | jmiller has override authority on all branches. |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: Branch Protection Setup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Preview mode (no changes)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
repos:
|
||||
description: 'Comma-separated repo names (empty = all governed repos)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
env:
|
||||
GITEA_URL: https://git.mokoconsulting.tech
|
||||
GITEA_ORG: MokoConsulting
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
protect:
|
||||
name: Apply Branch Protection Rules
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Determine target repos
|
||||
id: repos
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
|
||||
# Platform/standards/infra repos to exclude
|
||||
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
|
||||
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||
|
||||
if [ -n "${{ inputs.repos }}" ]; then
|
||||
# User-specified repos
|
||||
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
|
||||
else
|
||||
# Fetch all org repos
|
||||
PAGE=1
|
||||
REPOS=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
REPOS="$REPOS $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter out excluded repos
|
||||
FILTERED=""
|
||||
for REPO in $REPOS; do
|
||||
SKIP=false
|
||||
for EX in $EXCLUDE; do
|
||||
if [ "$REPO" = "$EX" ]; then
|
||||
SKIP=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$SKIP" = "false" ]; then
|
||||
FILTERED="$FILTERED $REPO"
|
||||
fi
|
||||
done
|
||||
REPOS="$FILTERED"
|
||||
fi
|
||||
|
||||
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$REPOS" | wc -w)
|
||||
echo "📋 Target repos (${COUNT}): $REPOS"
|
||||
|
||||
- name: Apply protection rules
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
REPOS="${{ steps.repos.outputs.repos }}"
|
||||
|
||||
SUCCESS=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
|
||||
# ── Rule definitions ──────────────────────────────────────
|
||||
# Each rule: NAME|JSON_BODY
|
||||
# jmiller has override (force push + push whitelist) on all branches
|
||||
|
||||
RULE_MAIN='{
|
||||
"rule_name": "main",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"dismiss_stale_approvals": true,
|
||||
"block_on_rejected_reviews": true,
|
||||
"block_on_outdated_branch": false,
|
||||
"priority": 1
|
||||
}'
|
||||
|
||||
RULE_DEV='{
|
||||
"rule_name": "dev",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": false,
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 2
|
||||
}'
|
||||
|
||||
RULE_RC='{
|
||||
"rule_name": "rc/*",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": false,
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 3
|
||||
}'
|
||||
|
||||
RULE_BETA='{
|
||||
"rule_name": "beta/*",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": false,
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 4
|
||||
}'
|
||||
|
||||
RULE_ALPHA='{
|
||||
"rule_name": "alpha/*",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": false,
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 5
|
||||
}'
|
||||
|
||||
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
|
||||
RULE_NAMES=("main" "dev" "rc/*" "beta/*" "alpha/*")
|
||||
|
||||
# ── Apply rules to each repo ──────────────────────────────
|
||||
for REPO in $REPOS; do
|
||||
echo ""
|
||||
echo "═══ ${REPO} ═══"
|
||||
|
||||
for i in "${!RULES[@]}"; do
|
||||
RULE="${RULES[$i]}"
|
||||
NAME="${RULE_NAMES[$i]}"
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY RUN] Would apply rule: ${NAME}"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Delete existing rule if present (idempotent recreate)
|
||||
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
||||
curl -sS -o /dev/null -w "" \
|
||||
-X DELETE \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
||||
|
||||
# Create rule
|
||||
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RULE" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
||||
|
||||
HTTP=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
if [ "$HTTP" = "201" ]; then
|
||||
echo " ✅ ${NAME}"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Success: ${SUCCESS}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo " ⏭️ Skipped: ${SKIPPED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "::warning::${FAILED} rule(s) failed to apply"
|
||||
fi
|
||||
@@ -0,0 +1,135 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/bulk-repo-sync.yml
|
||||
# BRIEF: Bulk repo sync — runs from API repo, syncs standards to all governed repos
|
||||
|
||||
name: Bulk Repository Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Preview mode (no changes)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
repos:
|
||||
description: 'Comma-separated repo names (empty = all)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
exclude:
|
||||
description: 'Comma-separated repos to skip'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
force:
|
||||
description: 'Force overwrite protected files'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
bulk-sync:
|
||||
name: Sync Standards to Repositories
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
extensions: json, mbstring, curl
|
||||
tools: composer
|
||||
coverage: none
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Build CLI Arguments
|
||||
id: args
|
||||
run: |
|
||||
ARGS="--org MokoConsulting"
|
||||
if [ "${{ inputs.dry_run }}" = "true" ] || [ "${{ gitea.event_name }}" = "schedule" ]; then
|
||||
ARGS="$ARGS --dry-run"
|
||||
fi
|
||||
if [ -n "${{ inputs.repos }}" ]; then
|
||||
ARGS="$ARGS --repos ${{ inputs.repos }}"
|
||||
fi
|
||||
if [ -n "${{ inputs.exclude }}" ]; then
|
||||
ARGS="$ARGS --exclude ${{ inputs.exclude }}"
|
||||
fi
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
ARGS="$ARGS --force"
|
||||
fi
|
||||
ARGS="$ARGS --yes"
|
||||
echo "args=$ARGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Bulk Sync
|
||||
run: |
|
||||
echo "Running: php automation/bulk_sync.php ${{ steps.args.outputs.args }}"
|
||||
php automation/bulk_sync.php ${{ steps.args.outputs.args }} 2>&1 | tee /tmp/bulk_sync.log
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
GIT_PLATFORM: gitea
|
||||
GITEA_URL: https://git.mokoconsulting.tech
|
||||
GITEA_ORG: MokoConsulting
|
||||
|
||||
- name: Commit Updated Definitions
|
||||
if: success() && inputs.dry_run != 'true'
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain definitions/sync/)" ]; then
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@git.mokoconsulting.tech"
|
||||
git add definitions/sync/*.def.tf
|
||||
git commit -m "chore: update synced repository definitions" || true
|
||||
git push || true
|
||||
fi
|
||||
|
||||
- name: Enforce Release Channel Tags
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "Enforcing standard tags on all repos..."
|
||||
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
bash automation/enforce_tags.sh --dry-run || echo "Tag enforcement skipped (non-fatal)"
|
||||
else
|
||||
bash automation/enforce_tags.sh || echo "Tag enforcement had errors (non-fatal)"
|
||||
fi
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
GITEA_URL: https://git.mokoconsulting.tech
|
||||
GITEA_ORG: MokoConsulting
|
||||
|
||||
- name: Upload Sync Log
|
||||
if: always() && github.server_url == 'https://github.com'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bulk-sync-log-${{ github.run_number }}
|
||||
path: /tmp/bulk_sync.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Log Summary (Gitea)
|
||||
if: always() && github.server_url != 'https://github.com'
|
||||
run: |
|
||||
if [ -f /tmp/bulk_sync.log ]; then
|
||||
echo "## Sync Log (last 20 lines)" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
tail -20 /tmp/bulk_sync.log >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,97 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: moko-platform.CI
|
||||
# INGROUP: moko-platform
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/pr-branch-check.yml
|
||||
# BRIEF: PR branch merge policy enforcement
|
||||
#
|
||||
# Enforces branch merge policy:
|
||||
# feature/* → dev only
|
||||
# fix/* → dev only
|
||||
# hotfix/* → dev or main (emergency)
|
||||
# dev → main only
|
||||
# alpha/* → dev only
|
||||
# beta/* → dev only
|
||||
# rc/* → main only
|
||||
|
||||
name: Branch Policy Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
jobs:
|
||||
check-target:
|
||||
name: Verify merge target
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch policy
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
alpha/*|beta/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Pre-release branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc/*)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo ""
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,128 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/renovate.yml
|
||||
# BRIEF: Run Renovate Bot across all governed repos for dependency updates
|
||||
#
|
||||
# +========================================================================+
|
||||
# | RENOVATE DEPENDENCY UPDATES |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Runs Renovate CLI against all governed repos to create PRs for |
|
||||
# | outdated dependencies (composer, npm). |
|
||||
# | |
|
||||
# | - Scheduled: weekly Wednesday 04:00 UTC |
|
||||
# | - Manual: dispatch with optional repo filter |
|
||||
# | - Patch updates auto-merge, minor/major require review |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: Renovate Dependency Updates
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * 3' # Weekly Wednesday 04:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repos:
|
||||
description: 'Comma-separated repo names (empty = all governed)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
dry_run:
|
||||
description: 'Preview mode (log only, no PRs)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
GITEA_URL: https://git.mokoconsulting.tech
|
||||
GITEA_ORG: MokoConsulting
|
||||
RENOVATE_VERSION: '39'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Run Renovate
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Determine target repos
|
||||
id: repos
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
|
||||
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
|
||||
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||
|
||||
if [ -n "${{ inputs.repos }}" ]; then
|
||||
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
|
||||
else
|
||||
PAGE=1
|
||||
REPOS=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
REPOS="$REPOS $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
FILTERED=""
|
||||
for REPO in $REPOS; do
|
||||
SKIP=false
|
||||
for EX in $EXCLUDE; do
|
||||
[ "$REPO" = "$EX" ] && SKIP=true && break
|
||||
done
|
||||
[ "$SKIP" = "false" ] && FILTERED="$FILTERED $REPO"
|
||||
done
|
||||
REPOS="$FILTERED"
|
||||
fi
|
||||
|
||||
# Build comma-separated list for Renovate
|
||||
REPO_LIST=""
|
||||
for REPO in $REPOS; do
|
||||
if [ -n "$REPO_LIST" ]; then
|
||||
REPO_LIST="${REPO_LIST},${GITEA_ORG}/${REPO}"
|
||||
else
|
||||
REPO_LIST="${GITEA_ORG}/${REPO}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "repo_list=$REPO_LIST" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$REPOS" | wc -w)
|
||||
echo "📋 Target repos (${COUNT})"
|
||||
|
||||
- name: Run Renovate
|
||||
if: steps.repos.outputs.repo_list != ''
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: ${{ env.GITEA_URL }}/api/v1
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@mokoconsulting.tech>'
|
||||
RENOVATE_REPOSITORIES: ${{ steps.repos.outputs.repo_list }}
|
||||
RENOVATE_DRY_RUN: ${{ inputs.dry_run == 'true' && 'full' || 'null' }}
|
||||
LOG_LEVEL: info
|
||||
run: |
|
||||
npx --yes renovate@${RENOVATE_VERSION} \
|
||||
--platform=gitea \
|
||||
--endpoint="${GITEA_URL}/api/v1" \
|
||||
--token="${RENOVATE_TOKEN}" \
|
||||
--git-author="Renovate Bot <renovate@mokoconsulting.tech>" \
|
||||
--autodiscover=false \
|
||||
${{ inputs.dry_run == 'true' && '--dry-run=full' || '' }} \
|
||||
2>&1 | tee /tmp/renovate.log
|
||||
|
||||
echo "### Renovate Summary" >> $GITHUB_STEP_SUMMARY
|
||||
grep -E "(INFO|WARN|ERROR)" /tmp/renovate.log | tail -30 >> $GITHUB_STEP_SUMMARY || true
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/sync-wikis.yml
|
||||
# BRIEF: Daily sync of all Gitea wikis to consolidated GitHub wiki repo
|
||||
|
||||
name: Sync Wikis to GitHub
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 5 * * *' # Daily at 5am UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
sync-wikis:
|
||||
name: Export wikis to GitHub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync all wikis
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::GH_TOKEN secret not set"
|
||||
exit 1
|
||||
fi
|
||||
bash scripts/sync-wikis-to-github.sh
|
||||
@@ -0,0 +1,666 @@
|
||||
# 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-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
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
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
# -- PLATFORM DETECTION ---------------------------------------------------
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
||||
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"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Step 1: Read version"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path .)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::No VERSION in README.md"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Step 1b: Bump version"
|
||||
id: bump
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
MOKO_API="/tmp/moko-platform-api/cli"
|
||||
BUMP=$(php ${MOKO_API}/version_bump.php --path . --minor)
|
||||
VERSION=$(echo "$BUMP" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true)
|
||||
[ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Bumped to: ${VERSION}"
|
||||
|
||||
- name: Check if already released
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
|
||||
TAG_EXISTS=false
|
||||
BRANCH_EXISTS=false
|
||||
|
||||
git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
|
||||
git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
|
||||
|
||||
echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Tag and branch may persist across patch releases — never skip
|
||||
echo "already_released=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# -- SANITY CHECKS -------------------------------------------------------
|
||||
- name: "Sanity: Pre-release validation"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
ERRORS=0
|
||||
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
echo "## Pre-Release Sanity Checks (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Version drift check (must pass before release) --------
|
||||
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)
|
||||
if [ "$README_VER" != "$VERSION" ]; then
|
||||
echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Check CHANGELOG version matches
|
||||
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)
|
||||
if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
|
||||
echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
|
||||
# Check composer.json version if present
|
||||
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
|
||||
echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Common checks
|
||||
if [ ! -f "LICENSE" ]; then
|
||||
echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
|
||||
echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- Platform-specific checks --------
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
XML_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
|
||||
echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- No Joomla XML manifest (WaaS site)" >> $GITHUB_STEP_SUMMARY
|
||||
fi ;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_VER=$(sed -n "s/.*\\\$this->version = '\([^']*\)'.*/\1/p" "$MOD_FILE" 2>/dev/null | head -1)
|
||||
if [ -n "$MOD_VER" ] && [ "$MOD_VER" != "$VERSION" ]; then
|
||||
echo "- Module drift: \`${MOD_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Module version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
echo "- No mod*.class.php found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
if [ ! -f "update.txt" ]; then
|
||||
echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi ;;
|
||||
*) echo "- Generic platform � no manifest checks" >> $GITHUB_STEP_SUMMARY ;;
|
||||
esac
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 2: Create or update version/XX.YY archive branch ---------------
|
||||
# Always runs — every version change on main archives to version/XX.YY
|
||||
- name: "Step 2: Version archive branch"
|
||||
if: steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
IS_MINOR="${{ steps.version.outputs.is_minor }}"
|
||||
PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
|
||||
|
||||
# Check if branch exists
|
||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
||||
git push origin HEAD:"$BRANCH" --force
|
||||
echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
|
||||
git push origin "$BRANCH" --force
|
||||
echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 3: Set platform version ----------------------------------------
|
||||
- name: "Step 3: Set platform version"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch main
|
||||
|
||||
# -- STEP 4: Update version badges ----------------------------------------
|
||||
- name: "Step 4: Update version badges"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/badge_update.php --path . --version "${VERSION}" 2>/dev/null || true
|
||||
php /tmp/moko-platform-api/cli/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
- name: "Step 5: Write update stream"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
|
||||
# Fetch latest updates.xml from main so preserve logic has all channels
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/contents/updates.xml?ref=main" 2>/dev/null | \
|
||||
python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin)['content']).decode())" \
|
||||
> updates.xml 2>/dev/null || true
|
||||
|
||||
php /tmp/moko-platform-api/cli/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability stable \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
--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,213 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/cascade-dev.yml.template
|
||||
# VERSION: 02.00.00
|
||||
# BRIEF: Forward-merge main → all open branches after every push to main
|
||||
#
|
||||
# +========================================================================+
|
||||
# | CASCADE MAIN → ALL BRANCHES |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Triggers on every push to main (PR merges, bot commits, etc.) |
|
||||
# | |
|
||||
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
|
||||
# | 2. For each: create PR (main → branch), auto-merge if clean |
|
||||
# | 3. On conflict: leave PR open for manual resolution |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Cascade Main → Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
cascade:
|
||||
name: Cascade main → branches
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
||||
|
||||
steps:
|
||||
- name: Discover target branches
|
||||
id: branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Fetch all branches (paginated)
|
||||
PAGE=1
|
||||
ALL_BRANCHES=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
|
||||
TARGETS=""
|
||||
for BRANCH in $ALL_BRANCHES; do
|
||||
case "$BRANCH" in
|
||||
dev|dev/*|rc/*|beta/*|alpha/*)
|
||||
TARGETS="$TARGETS $BRANCH"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
|
||||
|
||||
if [ -z "$TARGETS" ]; then
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "ℹ️ No cascade target branches found"
|
||||
else
|
||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$TARGETS" | wc -w)
|
||||
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
|
||||
fi
|
||||
|
||||
- name: Cascade to all target branches
|
||||
if: steps.branches.outputs.targets != ''
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TARGETS="${{ steps.branches.outputs.targets }}"
|
||||
|
||||
SUCCESS=0
|
||||
CONFLICTS=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
for BRANCH in $TARGETS; do
|
||||
echo ""
|
||||
echo "═══ main → ${BRANCH} ═══"
|
||||
|
||||
# Check if branch is already up to date
|
||||
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
|
||||
RESPONSE=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/compare/${ENCODED_BRANCH}...main")
|
||||
|
||||
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
|
||||
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo " ✅ Already up to date"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ℹ️ main is ${AHEAD} commit(s) ahead"
|
||||
|
||||
# Check for existing cascade PR
|
||||
EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
|
||||
|
||||
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
|
||||
PR_NUMBER=""
|
||||
|
||||
if [ "$EXISTING_COUNT" -gt 0 ]; then
|
||||
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
|
||||
echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
|
||||
else
|
||||
# Create cascade PR
|
||||
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
|
||||
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
|
||||
\"head\": \"main\",
|
||||
\"base\": \"${BRANCH}\"
|
||||
}" \
|
||||
"${API}/pulls")
|
||||
|
||||
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
||||
BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
||||
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
|
||||
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
|
||||
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ✅ Created PR #${PR_NUMBER}"
|
||||
fi
|
||||
|
||||
# Try auto-merge
|
||||
PR_DATA=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}")
|
||||
|
||||
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
|
||||
|
||||
if [ "$MERGEABLE" != "true" ]; then
|
||||
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"Do\": \"merge\",
|
||||
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
|
||||
\"delete_branch_after_merge\": false
|
||||
}" \
|
||||
"${API}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
|
||||
|
||||
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
||||
echo " ✅ Merged — ${BRANCH} is in sync"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
|
||||
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Merged: ${SUCCESS}"
|
||||
echo " ⚠️ Conflicts: ${CONFLICTS}"
|
||||
echo " ⏭️ Up to date: ${SKIPPED}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,439 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/ci-platform.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: moko-platform CI — the standards engine validates itself
|
||||
#
|
||||
# +========================================================================+
|
||||
# | MOKOSTANDARDS PLATFORM CI |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | This is NOT a generic CI workflow. This is the self-validation |
|
||||
# | pipeline for the central moko-platform enterprise engine. |
|
||||
# | |
|
||||
# | It dogfoods every tool the platform ships to governed repos: |
|
||||
# | |
|
||||
# | Gate 1 — Code Quality phpcs (PSR-12), phpstan (L5), psalm |
|
||||
# | Gate 2 — Unit Tests phpunit with coverage threshold |
|
||||
# | Gate 3 — Self-Health bin/moko health against its own repo |
|
||||
# | Gate 4 — Governance Checks headers, secrets, structure, versions |
|
||||
# | Gate 5 — Template Lint validate workflow templates parse clean |
|
||||
# | |
|
||||
# | If it doesn't pass its own checks, it can't enforce them. |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Platform: moko-platform CI"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- dev/**
|
||||
- rc/**
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'wiki/**'
|
||||
- '.gitea/ISSUE_TEMPLATE/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- dev/**
|
||||
- rc/**
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full_suite:
|
||||
description: 'Run full validation suite (including slow checks)'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ci-platform-${{ github.repository }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
PHP_VERSION: '8.2'
|
||||
|
||||
jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Gate 1 — Code Quality
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
code-quality:
|
||||
name: "Gate 1: Code Quality"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP ${{ env.PHP_VERSION }}
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
|
||||
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
|
||||
php${{ env.PHP_VERSION }}-intl composer >/dev/null 2>&1
|
||||
php -v
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: |
|
||||
composer install --no-interaction --prefer-dist
|
||||
echo "Dependencies installed: $(composer show | wc -l) packages"
|
||||
|
||||
- name: "PHP Syntax Check"
|
||||
run: |
|
||||
ERRORS=0
|
||||
CHECKED=0
|
||||
while IFS= read -r -d '' file; do
|
||||
CHECKED=$((CHECKED + 1))
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
echo "::error file=${file}::PHP syntax error"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find lib/ validate/ automation/ cli/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### PHP Syntax"
|
||||
echo "Checked ${CHECKED} files — ${ERRORS} error(s)"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
[ "$ERRORS" -eq 0 ] || exit 1
|
||||
|
||||
- name: "PHPCS (PSR-12)"
|
||||
run: |
|
||||
vendor/bin/phpcs --standard=phpcs.xml --report=summary --warning-severity=0 lib/ validate/ automation/ 2>&1 || {
|
||||
echo "::error::PHPCS found coding standard violations"
|
||||
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Coding standard violations detected. Run \`composer phpcs\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
}
|
||||
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
|
||||
echo "PSR-12 compliance: passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "PHPStan (Level 6)"
|
||||
run: |
|
||||
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --memory-limit=512M --error-format=github 2>&1 || {
|
||||
echo "::error::PHPStan found type errors"
|
||||
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis errors detected. Run \`composer phpstan\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
}
|
||||
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis (level 6): passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Psalm"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if [ -f "psalm.xml" ]; then
|
||||
vendor/bin/psalm --config=psalm.xml --no-progress --output-format=github 2>&1 || {
|
||||
echo "### Psalm" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Psalm found issues (advisory — not blocking)." >> $GITHUB_STEP_SUMMARY
|
||||
}
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Gate 2 — Unit Tests
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
tests:
|
||||
name: "Gate 2: Unit Tests"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: code-quality
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php${{ matrix.php }}-cli php${{ matrix.php }}-mbstring \
|
||||
php${{ matrix.php }}-xml php${{ matrix.php }}-curl php${{ matrix.php }}-zip \
|
||||
php${{ matrix.php }}-intl composer >/dev/null 2>&1
|
||||
php -v
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: "PHPUnit (PHP ${{ matrix.php }})"
|
||||
run: |
|
||||
vendor/bin/phpunit --testdox 2>&1 || {
|
||||
echo "::error::PHPUnit tests failed"
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests failed. Run \`vendor/bin/phpunit --testdox\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
}
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Gate 3 — Self-Health (Dogfood)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
self-health:
|
||||
name: "Gate 3: Self-Health Check"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: code-quality
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
|
||||
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
|
||||
composer >/dev/null 2>&1
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: "Run bin/moko health against self"
|
||||
run: |
|
||||
php bin/moko health -- --path . --json > /tmp/health-report.json 2>&1 || true
|
||||
SCORE=$(cat /tmp/health-report.json | python3 -c "import sys,json; print(json.load(sys.stdin).get('percentage', 0))" 2>/dev/null || echo "0")
|
||||
LEVEL=$(cat /tmp/health-report.json | python3 -c "import sys,json; print(json.load(sys.stdin).get('level', 'unknown'))" 2>/dev/null || echo "unknown")
|
||||
|
||||
{
|
||||
echo "### Self-Health Report"
|
||||
echo ""
|
||||
echo "| Metric | Value |"
|
||||
echo "|---|---|"
|
||||
echo "| Score | ${SCORE}% |"
|
||||
echo "| Level | ${LEVEL} |"
|
||||
echo ""
|
||||
echo "The platform must pass its own health check to enforce it on others."
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Platform must score at least 80%
|
||||
python3 -c "exit(0 if float('${SCORE}') >= 80.0 else 1)" || {
|
||||
echo "::error::Self-health score ${SCORE}% is below 80% threshold"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Gate 4 — Governance Checks
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
governance:
|
||||
name: "Gate 4: Governance"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: code-quality
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
|
||||
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl composer >/dev/null 2>&1
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: "License headers (SPDX)"
|
||||
run: |
|
||||
MISSING=0
|
||||
CHECKED=0
|
||||
while IFS= read -r -d '' file; do
|
||||
CHECKED=$((CHECKED + 1))
|
||||
if ! head -n 20 "$file" | grep -q "SPDX-License-Identifier:"; then
|
||||
echo "::warning file=${file}::Missing SPDX header"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### License Headers"
|
||||
echo "Checked ${CHECKED} files — ${MISSING} missing SPDX headers"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Advisory — warn but don't fail (yet)
|
||||
[ "$MISSING" -eq 0 ] || echo "::warning::${MISSING} files missing SPDX license headers"
|
||||
|
||||
- name: "Secret detection"
|
||||
run: |
|
||||
FOUND=0
|
||||
# Check for common secret patterns in source files
|
||||
while IFS= read -r -d '' file; do
|
||||
if grep -qEi '(password|secret|token|apikey|api_key)\s*[:=]\s*["\x27][^\s]{8,}' "$file" 2>/dev/null; then
|
||||
echo "::error file=${file}::Potential hardcoded secret detected"
|
||||
FOUND=$((FOUND + 1))
|
||||
fi
|
||||
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### Secret Detection"
|
||||
if [ "$FOUND" -eq 0 ]; then
|
||||
echo "No hardcoded secrets detected."
|
||||
else
|
||||
echo "${FOUND} potential secrets found."
|
||||
fi
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
[ "$FOUND" -eq 0 ] || exit 1
|
||||
|
||||
- name: "Version consistency"
|
||||
run: |
|
||||
# Extract version from composer.json
|
||||
COMPOSER_VER=$(python3 -c "import json; print(json.load(open('composer.json'))['version'])")
|
||||
# Extract version from README.md
|
||||
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)
|
||||
|
||||
{
|
||||
echo "### Version Consistency"
|
||||
echo "| Source | Version |"
|
||||
echo "|---|---|"
|
||||
echo "| composer.json | ${COMPOSER_VER} |"
|
||||
echo "| README.md | ${README_VER:-not found} |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ -n "$README_VER" ] && [ "$COMPOSER_VER" != "$README_VER" ]; then
|
||||
echo "::warning::Version mismatch: composer.json=${COMPOSER_VER} vs README.md=${README_VER}"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Gate 5 — Template Integrity
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
templates:
|
||||
name: "Gate 5: Template Integrity"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: code-quality
|
||||
if: github.event_name != 'push' || github.event.inputs.full_suite != 'false'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Validate workflow templates"
|
||||
run: |
|
||||
ERRORS=0
|
||||
CHECKED=0
|
||||
|
||||
# Check all YAML workflow templates parse cleanly
|
||||
while IFS= read -r -d '' file; do
|
||||
CHECKED=$((CHECKED + 1))
|
||||
if ! python3 -c "import yaml; yaml.safe_load(open('${file}'))" 2>/dev/null; then
|
||||
echo "::error file=${file}::Invalid YAML"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find templates/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0')
|
||||
|
||||
# Also check the live workflows
|
||||
while IFS= read -r -d '' file; do
|
||||
CHECKED=$((CHECKED + 1))
|
||||
if ! python3 -c "import yaml; yaml.safe_load(open('${file}'))" 2>/dev/null; then
|
||||
echo "::error file=${file}::Invalid YAML"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find .mokogitea/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0')
|
||||
|
||||
{
|
||||
echo "### Template Integrity"
|
||||
echo "Validated ${CHECKED} YAML files — ${ERRORS} parse errors"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
[ "$ERRORS" -eq 0 ] || exit 1
|
||||
|
||||
- name: "Validate gitignore templates"
|
||||
run: |
|
||||
TEMPLATES=0
|
||||
for GI in templates/configs/gitignore templates/configs/gitignore.dolibarr templates/configs/.gitignore.joomla; do
|
||||
if [ -f "$GI" ]; then
|
||||
TEMPLATES=$((TEMPLATES + 1))
|
||||
# Verify required entries
|
||||
for REQUIRED in ".claude/" "TODO.md" "*.min.css" "*.min.js" "wiki/"; do
|
||||
if ! grep -q "$REQUIRED" "$GI"; then
|
||||
echo "::error file=${GI}::Missing required entry: ${REQUIRED}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
echo "### Gitignore Templates" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Validated ${TEMPLATES} gitignore templates." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Validate PHP validation scripts"
|
||||
run: |
|
||||
ERRORS=0
|
||||
CHECKED=0
|
||||
while IFS= read -r -d '' file; do
|
||||
CHECKED=$((CHECKED + 1))
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
echo "::error file=${file}::Validation script has syntax error"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find validate/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### Validation Scripts"
|
||||
echo "Checked ${CHECKED} scripts — ${ERRORS} syntax errors"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::Validation scripts must be error-free"; exit 1; }
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Summary
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
summary:
|
||||
name: "CI Summary"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [code-quality, tests, self-health, governance, templates]
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Check gate results
|
||||
run: |
|
||||
{
|
||||
echo "# moko-platform CI"
|
||||
echo ""
|
||||
echo "| Gate | Job | Status |"
|
||||
echo "|---|---|---|"
|
||||
echo "| 1 | Code Quality | ${{ needs.code-quality.result }} |"
|
||||
echo "| 2 | Unit Tests | ${{ needs.tests.result }} |"
|
||||
echo "| 3 | Self-Health | ${{ needs.self-health.result }} |"
|
||||
echo "| 4 | Governance | ${{ needs.governance.result }} |"
|
||||
echo "| 5 | Templates | ${{ needs.templates.result }} |"
|
||||
echo ""
|
||||
echo "> *The standards engine must pass its own standards.*"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Fail if any required gate failed
|
||||
if [ "${{ needs.code-quality.result }}" = "failure" ] || \
|
||||
[ "${{ needs.tests.result }}" = "failure" ] || \
|
||||
[ "${{ needs.self-health.result }}" = "failure" ] || \
|
||||
[ "${{ needs.governance.result }}" = "failure" ] || \
|
||||
[ "${{ needs.templates.result }}" = "failure" ]; then
|
||||
echo "::error::One or more CI gates failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,87 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||
|
||||
name: "Universal: Repository Cleanup"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Clean Merged Branches
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Delete merged branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Merged Branch Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
# List branches via API
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||
|
||||
DELETED=0
|
||||
for BRANCH in $BRANCHES; do
|
||||
# Skip protected branches
|
||||
case "$BRANCH" in
|
||||
main|master|develop|release/*|hotfix/*) continue ;;
|
||||
esac
|
||||
|
||||
# Check if branch is merged into main
|
||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||
echo " Deleting merged branch: ${BRANCH}"
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Deleted ${DELETED} merged branch(es)"
|
||||
|
||||
- name: Clean old workflow runs
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Workflow Run Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Get old completed runs
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/actions/runs?status=completed&limit=50" | \
|
||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||
|
||||
DELETED=0
|
||||
for RUN_ID in $RUNS; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
done
|
||||
|
||||
echo "Deleted ${DELETED} old workflow run(s)"
|
||||
+30
-21
@@ -4,15 +4,13 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Deploy
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# INGROUP: moko-platform.Deploy
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||
# NOTE: Joomla repos use update.xml for distribution. This is for manual
|
||||
# dev server testing only — triggered via workflow_dispatch.
|
||||
|
||||
name: Deploy to Dev (Manual)
|
||||
name: "Universal: Deploy to Dev (Manual)"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -42,18 +40,18 @@ jobs:
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
- 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 {{standards_branch}} --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
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
|
||||
@@ -61,11 +59,10 @@ jobs:
|
||||
env:
|
||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||
SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
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 "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -73,7 +70,6 @@ jobs:
|
||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REMOTE="${PATH_VAR%/}"
|
||||
[ -n "$SUFFIX" ] && REMOTE="${REMOTE}/${SUFFIX#/}"
|
||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
[ -z "$PORT" ] && PORT="22"
|
||||
@@ -88,7 +84,7 @@ jobs:
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — nothing to deploy"; exit 0; }
|
||||
[ ! -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 }}" \
|
||||
@@ -105,20 +101,33 @@ jobs:
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
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/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
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
|
||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,96 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/gitleaks.yml.template
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||
#
|
||||
# +========================================================================+
|
||||
# | SECRET SCANNING |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Scans commits for leaked secrets using Gitleaks. |
|
||||
# | |
|
||||
# | - PR scan: only new commits in the PR |
|
||||
# | - Scheduled: full repo scan weekly |
|
||||
# | - Alerts via ntfy on findings |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Secret Scanning"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- 'dev/**'
|
||||
schedule:
|
||||
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: Gitleaks Secret Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Gitleaks
|
||||
run: |
|
||||
GITLEAKS_VERSION="8.21.2"
|
||||
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin gitleaks
|
||||
gitleaks version
|
||||
|
||||
- name: Scan for secrets
|
||||
id: scan
|
||||
run: |
|
||||
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
|
||||
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
# Scan only PR commits
|
||||
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
|
||||
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if gitleaks detect $ARGS 2>&1; then
|
||||
echo "result=clean" >> "$GITHUB_OUTPUT"
|
||||
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "result=found" >> "$GITHUB_OUTPUT"
|
||||
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
|
||||
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on findings
|
||||
if: failure() && steps.scan.outputs.result == 'found'
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} — secrets detected in code" \
|
||||
-H "Tags: rotating_light,key" \
|
||||
-H "Priority: urgent" \
|
||||
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
create-branch:
|
||||
name: Create feature branch
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
# Build slug from title: lowercase, replace non-alnum with dash, trim
|
||||
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
|
||||
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
|
||||
|
||||
# Check dev branch exists
|
||||
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/branches/dev" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${DEV_EXISTS}" != "200" ]; then
|
||||
echo "No dev branch -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create branch from dev
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/branches" \
|
||||
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${HTTP}" = "201" ]; then
|
||||
echo "Created branch: ${BRANCH}"
|
||||
|
||||
# Comment on issue with branch link
|
||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/comments" \
|
||||
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
|
||||
|
||||
echo "Commented on issue #${ISSUE_NUM}"
|
||||
else
|
||||
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
|
||||
fi
|
||||
@@ -0,0 +1,70 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/notify.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||
|
||||
name: "Universal: Notifications"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Joomla Build & Release"
|
||||
- "Joomla Extension CI"
|
||||
- "Deploy"
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
name: Send Notification
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' ||
|
||||
github.event.workflow_run.conclusion == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Notify on success (releases only)
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
contains(github.event.workflow_run.name, 'Release')
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||
URL="${{ github.event.workflow_run.html_url }}"
|
||||
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} released" \
|
||||
-H "Tags: white_check_mark,package" \
|
||||
-H "Priority: default" \
|
||||
-H "Click: ${URL}" \
|
||||
-d "${WORKFLOW} completed successfully." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}"
|
||||
|
||||
- name: Notify on failure
|
||||
if: github.event.workflow_run.conclusion == 'failure'
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||
URL="${{ github.event.workflow_run.html_url }}"
|
||||
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} workflow failed" \
|
||||
-H "Tags: x,warning" \
|
||||
-H "Priority: high" \
|
||||
-H "Click: ${URL}" \
|
||||
-d "${WORKFLOW} failed. Check the run for details." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}"
|
||||
@@ -0,0 +1,214 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
alpha/*|beta/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Pre-release branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc/*)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,375 @@
|
||||
# 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: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
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 }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
runs-on: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup tools
|
||||
run: |
|
||||
# Update moko-platform CLI tools if available; install PHP if missing
|
||||
if command -v moko-platform-update &> /dev/null; then
|
||||
moko-platform-update
|
||||
elif [ -d "/opt/moko-platform" ]; then
|
||||
cd /opt/moko-platform && git pull origin main --quiet 2>/dev/null || true
|
||||
else
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
fi
|
||||
# Set MOKO_CLI to whichever path exists
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Patch bump via CLI tool
|
||||
php ${MOKO_CLI}/version_bump.php --path .
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
# Update platform-specific manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element (platform-aware)
|
||||
EXT_ELEMENT=""
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
|
||||
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
;;
|
||||
esac
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::error::No src/ or htdocs/ directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST="${{ steps.meta.outputs.manifest }}"
|
||||
EXT_TYPE=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
fi
|
||||
|
||||
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
|
||||
|
||||
mkdir -p build/package
|
||||
|
||||
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ==="
|
||||
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
|
||||
[ ! -d "$ext_dir" ] && continue
|
||||
EXT_NAME=$(basename "$ext_dir")
|
||||
echo " Packaging sub-extension: ${EXT_NAME}"
|
||||
cd "$ext_dir"
|
||||
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
|
||||
cd "$OLDPWD"
|
||||
done
|
||||
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
|
||||
[ -f "$f" ] && cp "$f" build/package/
|
||||
done
|
||||
else
|
||||
echo "=== Building standard extension ==="
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
fi
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
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
|
||||
+94
-114
@@ -7,19 +7,14 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /.github/workflows/repo_health.yml
|
||||
# INGROUP: moko-platform.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# NOTE: Field is user-managed.
|
||||
# ============================================================================
|
||||
|
||||
name: Repo Health
|
||||
|
||||
concurrency:
|
||||
group: repo-health-${{ github.repository }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
name: "Generic: Repo Health"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -50,13 +45,11 @@ env:
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
# Note: directories listed without a trailing slash.
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
|
||||
# Repo health policy
|
||||
# Files are listed as-is; directories must end with a trailing slash.
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.github/workflows/
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/
|
||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||
REPO_DISALLOWED_DIRS:
|
||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||
@@ -64,10 +57,10 @@ env:
|
||||
# Extended checks toggles
|
||||
EXTENDED_CHECKS: "true"
|
||||
|
||||
# File / directory variables (moved to top-level env)
|
||||
# File / directory variables
|
||||
DOCS_INDEX: docs/docs-index.md
|
||||
SCRIPT_DIR: scripts
|
||||
WORKFLOWS_DIR: .github/workflows
|
||||
WORKFLOWS_DIR: .mokogitea/workflows
|
||||
SHELLCHECK_PATTERN: '*.sh'
|
||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -99,7 +92,7 @@ jobs:
|
||||
|
||||
# Hardcoded authorized users — always allowed
|
||||
case "$ACTOR" in
|
||||
jmiller-moko|github-actions\[bot\])
|
||||
jmiller|gitea-actions[bot])
|
||||
ALLOWED=true
|
||||
PERMISSION=admin
|
||||
METHOD="hardcoded allowlist"
|
||||
@@ -121,7 +114,7 @@ jobs:
|
||||
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "## 🔐 Access Authorization"
|
||||
echo "## Access Authorization"
|
||||
echo ""
|
||||
echo "| Field | Value |"
|
||||
echo "|-------|-------|"
|
||||
@@ -132,9 +125,9 @@ jobs:
|
||||
echo "| **Authorized** | ${ALLOWED} |"
|
||||
echo ""
|
||||
if [ "$ALLOWED" = "true" ]; then
|
||||
echo "✅ ${ACTOR} authorized (${METHOD})"
|
||||
echo "${ACTOR} authorized (${METHOD})"
|
||||
else
|
||||
echo "❌ ${ACTOR} is NOT authorized. Requires admin or maintain role."
|
||||
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
|
||||
fi
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
@@ -291,7 +284,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
|
||||
if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi
|
||||
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
|
||||
|
||||
missing_dirs=()
|
||||
@@ -395,23 +388,27 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid)
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid for extension repos)
|
||||
SOURCE_DIR=""
|
||||
if [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||
# Platform/tooling repos don't need src/
|
||||
SOURCE_DIR=""
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
if printf '%s' "${item}" | grep -q '/$'; then
|
||||
d="${item%/}"
|
||||
@@ -421,7 +418,6 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# Optional entries: handle files and directories (trailing slash indicates dir)
|
||||
for f in "${optional_files[@]}"; do
|
||||
if printf '%s' "${f}" | grep -q '/$'; then
|
||||
d="${f%/}"
|
||||
@@ -445,8 +441,6 @@ jobs:
|
||||
dev_paths=()
|
||||
dev_branches=()
|
||||
|
||||
# Look for remote branches matching origin/dev*.
|
||||
# A plain origin/dev is considered invalid; we require dev/<something> branches.
|
||||
while IFS= read -r b; do
|
||||
name="${b#origin/}"
|
||||
if [ "${name}" = 'dev' ]; then
|
||||
@@ -456,14 +450,8 @@ jobs:
|
||||
fi
|
||||
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
||||
|
||||
# If there are no dev/* branches, fail the guardrail.
|
||||
if [ "${#dev_paths[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev/* branch (e.g. dev/01.00.00)")
|
||||
fi
|
||||
|
||||
# If a plain dev branch exists (origin/dev), flag it as invalid.
|
||||
if [ "${#dev_branches[@]}" -gt 0 ]; then
|
||||
missing_required+=("invalid branch dev (must be dev/<version>)")
|
||||
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev or dev/* branch")
|
||||
fi
|
||||
|
||||
content_warnings=()
|
||||
@@ -489,26 +477,7 @@ jobs:
|
||||
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
|
||||
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
|
||||
|
||||
report_json="$(python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
profile = os.environ.get('PROFILE_RAW') or 'all'
|
||||
|
||||
missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
|
||||
missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
|
||||
content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
|
||||
|
||||
out = {
|
||||
'profile': profile,
|
||||
'missing_required': [x for x in missing_required if x],
|
||||
'missing_optional': [x for x in missing_optional if x],
|
||||
'content_warnings': [x for x in content_warnings if x],
|
||||
}
|
||||
|
||||
print(json.dumps(out, indent=2))
|
||||
PY
|
||||
)"
|
||||
report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
@@ -553,54 +522,47 @@ jobs:
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
# ── Joomla-specific checks ───────────────────────────────────────
|
||||
# -- Joomla-specific checks --
|
||||
joomla_findings=()
|
||||
|
||||
# XML manifest: find any XML file containing <extension
|
||||
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
|
||||
if [ -z "${MANIFEST}" ]; then
|
||||
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
|
||||
else
|
||||
# Check <version> tag exists
|
||||
if ! grep -qP '<version>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <version> tag missing")
|
||||
fi
|
||||
# Check extension type attribute
|
||||
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: type attribute missing or invalid")
|
||||
fi
|
||||
# Check <name> tag
|
||||
if ! grep -qP '<name>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <name> tag missing")
|
||||
fi
|
||||
# Check <author> tag
|
||||
if ! grep -qP '<author>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <author> tag missing")
|
||||
fi
|
||||
# Check <namespace> for Joomla 5+
|
||||
if ! grep -qP '<namespace' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Language files: check for at least one .ini file
|
||||
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
|
||||
if [ "${INI_COUNT}" -eq 0 ]; then
|
||||
joomla_findings+=("No .ini language files found")
|
||||
fi
|
||||
|
||||
# updates.xml must exist in root (Joomla update server)
|
||||
if [ ! -f 'updates.xml' ]; then
|
||||
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
|
||||
fi
|
||||
|
||||
# index.html files for directory listing protection
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
if [ -n "${SOURCE_DIR}" ]; then
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#joomla_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
@@ -624,14 +586,12 @@ jobs:
|
||||
extended_findings=()
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
# CODEOWNERS presence
|
||||
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
|
||||
:
|
||||
else
|
||||
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
|
||||
fi
|
||||
|
||||
# Workflow pinning advisory: flag uses @main/@master
|
||||
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
|
||||
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
|
||||
if [ -n "${bad_refs}" ]; then
|
||||
@@ -647,51 +607,35 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# Docs index link integrity (docs/docs-index.md)
|
||||
if [ -f "${DOCS_INDEX}" ]; then
|
||||
missing_links="$(python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
|
||||
idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
|
||||
base = os.getcwd()
|
||||
|
||||
bad = []
|
||||
pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
|
||||
|
||||
with open(idx, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
for m in pat.findall(line):
|
||||
link = m.strip()
|
||||
if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
|
||||
continue
|
||||
if link.startswith('/'):
|
||||
rel = link.lstrip('/')
|
||||
else:
|
||||
rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
|
||||
rel = rel.split('#', 1)[0]
|
||||
rel = rel.split('?', 1)[0]
|
||||
if not rel:
|
||||
continue
|
||||
p = os.path.join(base, rel)
|
||||
if not os.path.exists(p):
|
||||
bad.append(rel)
|
||||
|
||||
print('\n'.join(sorted(set(bad))))
|
||||
PY
|
||||
)"
|
||||
missing_links=""
|
||||
while IFS= read -r docline; do
|
||||
for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
|
||||
case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
|
||||
linkpath="${link%%#*}"
|
||||
linkpath="${linkpath%%\?*}"
|
||||
[ -z "$linkpath" ] && continue
|
||||
if [ "${linkpath:0:1}" = "/" ]; then
|
||||
testpath="${linkpath#/}"
|
||||
else
|
||||
testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
|
||||
fi
|
||||
[ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
|
||||
done
|
||||
done < "${DOCS_INDEX}"
|
||||
if [ -n "${missing_links}" ]; then
|
||||
extended_findings+=("docs/docs-index.md contains broken relative links")
|
||||
{
|
||||
printf '%s\n' '### Docs index link integrity'
|
||||
printf '%s\n' 'Broken relative links:'
|
||||
while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
|
||||
for bl in ${missing_links}; do
|
||||
printf '%s\n' "- ${bl}"
|
||||
done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ShellCheck advisory
|
||||
if [ -d "${SCRIPT_DIR}" ]; then
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
sudo apt-get update -qq
|
||||
@@ -720,7 +664,6 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# SPDX header advisory for common source types
|
||||
spdx_missing=()
|
||||
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
|
||||
spdx_args=()
|
||||
@@ -743,9 +686,8 @@ jobs:
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
# Git hygiene advisory: branches older than 180 days (remote)
|
||||
stale_cutoff_days=180
|
||||
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 [...]
|
||||
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
|
||||
if [ -n "${stale_branches}" ]; then
|
||||
extended_findings+=("Stale remote branches detected (advisory)")
|
||||
{
|
||||
@@ -787,3 +729,41 @@ jobs:
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
|
||||
site-health:
|
||||
name: Site Health
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
|
||||
- name: Uptime check
|
||||
if: env.URLS != ''
|
||||
run: |
|
||||
echo "$URLS" > /tmp/urls.txt
|
||||
php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down"
|
||||
rm -f /tmp/urls.txt
|
||||
env:
|
||||
URLS: ${{ vars.MONITORED_URLS }}
|
||||
|
||||
- name: SSL certificate check
|
||||
if: env.DOMAINS != ''
|
||||
run: |
|
||||
echo "$DOMAINS" > /tmp/domains.txt
|
||||
php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon"
|
||||
rm -f /tmp/domains.txt
|
||||
env:
|
||||
DOMAINS: ${{ vars.MONITORED_DOMAINS }}
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
name: "Universal: Security Audit"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Composer audit
|
||||
if: hashFiles('composer.lock') != ''
|
||||
run: |
|
||||
echo "=== Composer Security Audit ==="
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
||||
fi
|
||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
||||
RESULT=$?
|
||||
if [ $RESULT -ne 0 ]; then
|
||||
echo "::warning::Composer vulnerabilities found"
|
||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No known vulnerabilities in composer dependencies"
|
||||
fi
|
||||
|
||||
- name: NPM audit
|
||||
if: hashFiles('package-lock.json') != ''
|
||||
run: |
|
||||
echo "=== NPM Security Audit ==="
|
||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
||||
echo "No known vulnerabilities in npm dependencies"
|
||||
else
|
||||
echo "::warning::NPM vulnerabilities found"
|
||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Notify on vulnerabilities
|
||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
||||
-H "Tags: lock,warning" \
|
||||
-H "Priority: high" \
|
||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
|
||||
|
||||
- name: Joomla version audit
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
|
||||
echo "$JOOMLA_SITES" > /tmp/sites.json
|
||||
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
|
||||
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
|
||||
rm -f /tmp/sites.json
|
||||
else
|
||||
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
|
||||
fi
|
||||
env:
|
||||
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Root
|
||||
INGROUP: MokoStandards
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /CHANGELOG.md
|
||||
BRIEF: Release changelog
|
||||
-->
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version format: `XX.YY.ZZ` (zero-padded semver).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [09.00.00] - 2026-05-26
|
||||
|
||||
### Added
|
||||
- PHPDoc on Priority 1 Enterprise classes (CliFramework, adapters, ApiClient)
|
||||
- Wiki: Coding-Standards page with PHPDoc standard, PHPCS exclusions, file patterns
|
||||
- CI: PHPStan enforced at level 6 (was advisory), PHPUnit blocks on failure
|
||||
|
||||
### Fixed
|
||||
- `updates_xml_build.php`: cascade entries down to lower channels — stable now writes all 5 entries instead of wiping them
|
||||
- `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
|
||||
- `updates_xml_build.php`: only emit `<client>site</client>` for templates and modules, not packages or components
|
||||
- `updates_xml_build.php`: preservation logic matches Joomla tag names when deciding which existing entries to keep
|
||||
|
||||
## [08.00.00] - 2026-05-26
|
||||
|
||||
### Changed
|
||||
- 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
|
||||
@@ -0,0 +1,103 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**moko-platform** — Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Default branch** | main |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Version** | 06.00.00 |
|
||||
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
php bin/moko health --path . # Run repo health check
|
||||
php bin/moko check:syntax --path . # PHP syntax check
|
||||
php bin/moko drift --org MokoConsulting # Scan for standards drift
|
||||
php bin/moko dashboard --token $TOKEN -o dashboard.html # Generate client dashboard
|
||||
|
||||
# Code quality
|
||||
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
|
||||
|
||||
# Run all checks
|
||||
composer check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Layout
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
|
||||
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
|
||||
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
|
||||
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
||||
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
||||
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
||||
| `definitions/` | Repository structure definitions (HCL format) |
|
||||
| `templates/` | Workflow templates, config templates, docs templates |
|
||||
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
||||
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
|
||||
|
||||
### CLI Framework
|
||||
|
||||
All CLI tools extend `MokoEnterprise\CliFramework` (defined in `lib/Enterprise/CliFramework.php`).
|
||||
|
||||
Pattern for new tools:
|
||||
```php
|
||||
class MyTool extends CliFramework {
|
||||
protected function configure(): void {
|
||||
$this->setDescription('What this tool does');
|
||||
$this->addArgument('--name', 'Description', 'default');
|
||||
}
|
||||
protected function run(): int {
|
||||
$name = $this->getArgument('--name');
|
||||
// ... business logic ...
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
$app = new MyTool();
|
||||
exit($app->execute());
|
||||
```
|
||||
|
||||
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`
|
||||
|
||||
### Platform Adapters
|
||||
|
||||
Git operations are abstracted via `GitPlatformAdapter` interface:
|
||||
- `MokoGiteaAdapter` — for git.mokoconsulting.tech (primary)
|
||||
- `GitHubAdapter` — for github.com mirrors
|
||||
|
||||
### Plugin System
|
||||
|
||||
Platform-specific logic lives in `lib/Enterprise/Plugins/`. Each plugin implements `ProjectPluginInterface` with methods for health checks, validation, build commands, and config schemas.
|
||||
|
||||
## Code Quality
|
||||
|
||||
| Tool | Level | Config |
|
||||
|------|-------|--------|
|
||||
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
|
||||
| PHPStan | Level 2 | `phpstan.neon` |
|
||||
|
||||
PHPStan runs with `--memory-limit=512M` due to large codebase. CI enforces PHPCS errors; PHPStan is advisory (`continue-on-error`).
|
||||
|
||||
## Rules
|
||||
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
|
||||
- **After adding a CLI tool**: register it in `bin/moko` COMMAND_MAP
|
||||
@@ -0,0 +1,40 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project team at hello@mokoconsulting.tech.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/),
|
||||
version 2.1.
|
||||
|
||||
---
|
||||
|
||||
*Moko Consulting <hello@mokoconsulting.tech>*
|
||||
@@ -0,0 +1,30 @@
|
||||
# Contributing to moko-platform
|
||||
|
||||
Thank you for your interest in contributing to the Moko Consulting platform.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork** the repository
|
||||
2. Create a **feature branch** from `dev` (e.g., `feature/my-feature`)
|
||||
3. Make your changes following [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
4. Submit a **Pull Request** targeting `dev`
|
||||
|
||||
## Branch Policy
|
||||
|
||||
- `feature/*`, `fix/*` branches target `dev`
|
||||
- `hotfix/*` branches may target `dev` or `main`
|
||||
- `dev` merges to `main` for releases
|
||||
|
||||
## Code Standards
|
||||
|
||||
- PHP: follow PSR-12, use tabs for indentation
|
||||
- All files must include the Moko copyright header and SPDX identifier
|
||||
- Scripts must be self-contained (no external dependencies unless via composer)
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use the [issue tracker](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/issues) with the appropriate template.
|
||||
|
||||
---
|
||||
|
||||
*Moko Consulting <hello@mokoconsulting.tech>*
|
||||
@@ -0,0 +1,35 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
For the full license text, see <https://www.gnu.org/licenses/gpl-3.0.txt>
|
||||
|
||||
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.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
@@ -1,3 +1,14 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Root
|
||||
INGROUP: MokoStandards
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /PLUGIN_SCRIPTS.md
|
||||
BRIEF: Plugin system CLI documentation
|
||||
-->
|
||||
|
||||
# Plugin System CLI Scripts
|
||||
|
||||
Command-line scripts for validating, health checking, and managing projects using the MokoStandards plugin system.
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Root
|
||||
INGROUP: MokoStandards
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /README.md
|
||||
BRIEF: Project overview and documentation
|
||||
-->
|
||||
|
||||
# MokoStandards Enterprise API
|
||||
|
||||
PHP implementation of MokoStandards — enterprise standards, automation framework, workflow templates, and bulk sync tooling.
|
||||
@@ -17,7 +28,7 @@ PHP implementation of MokoStandards — enterprise standards, automation framewo
|
||||
| `definitions/` | Repository structure definitions (`.tf` format) |
|
||||
| `deploy/` | Deployment scripts (SFTP, Joomla) |
|
||||
| `maintenance/` | Labels, inventory, SHA pinning, version sync |
|
||||
| `docs/` | API documentation, workflow guides, automation docs |
|
||||
| `tools/` | Standalone tools (legal doc generator) |
|
||||
| `tests/` | PHPUnit test suite |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Index
|
||||
INGROUP: MokoStandards.Analysis
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /analysis/index.md
|
||||
BRIEF: Analysis directory index
|
||||
-->
|
||||
|
||||
# Docs Index: /api/analysis
|
||||
|
||||
## Purpose
|
||||
|
||||
+569
-559
File diff suppressed because it is too large
Load Diff
+110
-95
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -10,9 +11,8 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards.Scripts
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/bulk_sync.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Enterprise-grade bulk repository synchronization
|
||||
*/
|
||||
|
||||
@@ -26,7 +26,7 @@ use MokoEnterprise\{
|
||||
AuditLogger,
|
||||
CheckpointManager,
|
||||
CircuitBreakerOpen,
|
||||
CLIApp,
|
||||
CliFramework,
|
||||
Config,
|
||||
GitPlatformAdapter,
|
||||
MetricsCollector,
|
||||
@@ -41,18 +41,18 @@ use MokoEnterprise\{
|
||||
|
||||
/**
|
||||
* Bulk Repository Synchronization Tool
|
||||
*
|
||||
*
|
||||
* Synchronizes MokoStandards files across multiple repositories using
|
||||
* the Enterprise library for robust, audited operations.
|
||||
*/
|
||||
class BulkSync extends CLIApp
|
||||
class BulkSync extends CliFramework
|
||||
{
|
||||
/**
|
||||
* Default organization for bulk sync operations
|
||||
* Public to allow script instantiation with class constants
|
||||
*/
|
||||
public const DEFAULT_ORG = 'MokoConsulting';
|
||||
|
||||
|
||||
/**
|
||||
* Script version number
|
||||
* Public to allow script instantiation with class constants
|
||||
@@ -65,55 +65,52 @@ class BulkSync extends CLIApp
|
||||
private RepositorySynchronizer $synchronizer;
|
||||
private AuditLogger $logger;
|
||||
private CheckpointManager $checkpoints;
|
||||
private SecurityValidator $security;
|
||||
private PluginFactory $pluginFactory;
|
||||
private ProjectTypeDetector $typeDetector;
|
||||
private MetricsCollector $metrics;
|
||||
private Config $config;
|
||||
|
||||
/** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */
|
||||
private bool $interrupted = false;
|
||||
|
||||
|
||||
/**
|
||||
* Setup command-line arguments
|
||||
*/
|
||||
protected function setupArguments(): array
|
||||
protected function configure(): void
|
||||
{
|
||||
return [
|
||||
'org:' => 'GitHub organization (default: MokoConsulting)',
|
||||
'repos:' => 'Specific repositories to sync (space-separated)',
|
||||
'exclude:' => 'Repositories to exclude (space-separated)',
|
||||
'skip-archived' => 'Skip archived repositories',
|
||||
'yes' => 'Auto-confirm prompts',
|
||||
'resume' => 'Resume from last checkpoint, skipping already-processed repositories',
|
||||
'force' => 'Force overwrite of protected files (always_overwrite=false), except truly protected files',
|
||||
'protect' => 'Apply/enforce main branch protection rules on all synced repositories',
|
||||
'no-issue' => 'Skip creating a tracking issue in each target repository',
|
||||
'update-branches' => 'After sync, merge main into all other open PR branches in each repo',
|
||||
'health' => 'Run repo health checks after sync and include results in the report',
|
||||
];
|
||||
$this->setDescription('Bulk repository synchronization');
|
||||
$this->addArgument('--org', 'Organization', self::DEFAULT_ORG);
|
||||
$this->addArgument('--repos', 'Specific repos', '');
|
||||
$this->addArgument('--exclude', 'Repos to exclude', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repos', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm', false);
|
||||
$this->addArgument('--resume', 'Resume from checkpoint', false);
|
||||
$this->addArgument('--force', 'Force overwrite', false);
|
||||
$this->addArgument('--protect', 'Apply branch protection', false);
|
||||
$this->addArgument('--no-issue', 'Skip tracking issue', false);
|
||||
$this->addArgument('--update-branches', 'Merge main into branches', false);
|
||||
$this->addArgument('--health', 'Run health checks', false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Main execution
|
||||
*/
|
||||
protected function run(): int
|
||||
{
|
||||
$this->log("🚀 MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO');
|
||||
|
||||
|
||||
// Initialize enterprise components
|
||||
if (!$this->initializeComponents()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
// Get configuration
|
||||
$org = $this->getOption('org', self::DEFAULT_ORG);
|
||||
$skipArchived = $this->hasOption('skip-archived');
|
||||
$autoConfirm = $this->hasOption('yes');
|
||||
|
||||
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||
$skipArchived = $this->getArgument('--skip-archived', false);
|
||||
$autoConfirm = $this->getArgument('--yes', false);
|
||||
|
||||
// Get repository filters
|
||||
$specificRepos = $this->parseRepositoryList($this->getOption('repos', ''));
|
||||
$excludeRepos = $this->parseRepositoryList($this->getOption('exclude', ''));
|
||||
|
||||
$specificRepos = $this->parseRepositoryList($this->getArgument('--repos', ''));
|
||||
$excludeRepos = $this->parseRepositoryList($this->getArgument('--exclude', ''));
|
||||
|
||||
$this->log("Organization: {$org}", 'INFO');
|
||||
if (!empty($specificRepos)) {
|
||||
$this->log("Repositories: " . implode(', ', $specificRepos), 'INFO');
|
||||
@@ -121,25 +118,25 @@ class BulkSync extends CLIApp
|
||||
if (!empty($excludeRepos)) {
|
||||
$this->log("Excluding: " . implode(', ', $excludeRepos), 'INFO');
|
||||
}
|
||||
|
||||
|
||||
// Get repositories
|
||||
$this->log("📋 Fetching repositories...", 'INFO');
|
||||
$repositories = $this->synchronizer->getRepositories($org, $skipArchived);
|
||||
|
||||
|
||||
// Apply filters
|
||||
$repositories = $this->filterRepositories($repositories, $specificRepos, $excludeRepos);
|
||||
|
||||
$count = count($repositories);
|
||||
$this->log("Found {$count} repositories to sync", 'INFO');
|
||||
|
||||
|
||||
if ($count === 0) {
|
||||
$this->log("No repositories to process", 'WARN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// Load resume checkpoint if --resume is set
|
||||
$alreadyProcessed = [];
|
||||
if ($this->hasOption('resume')) {
|
||||
if ($this->getArgument('--resume', false)) {
|
||||
$checkpoint = $this->checkpoints->loadCheckpoint('bulk_sync');
|
||||
if ($checkpoint !== null) {
|
||||
$alreadyProcessed = array_keys($checkpoint['results']['repositories'] ?? []);
|
||||
@@ -162,7 +159,7 @@ class BulkSync extends CLIApp
|
||||
// Execute synchronization
|
||||
$this->log("🔄 Starting synchronization...", 'INFO');
|
||||
$results = $this->executeSynchronization($org, $repositories, $alreadyProcessed);
|
||||
|
||||
|
||||
// Display results
|
||||
$this->displayResults($results);
|
||||
|
||||
@@ -188,7 +185,7 @@ class BulkSync extends CLIApp
|
||||
|
||||
return $results['failed'] > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize enterprise components
|
||||
*/
|
||||
@@ -204,7 +201,6 @@ class BulkSync extends CLIApp
|
||||
$this->logger = new AuditLogger('bulk_sync');
|
||||
$this->metrics = new MetricsCollector();
|
||||
$this->checkpoints = new CheckpointManager('.checkpoints');
|
||||
$this->security = new SecurityValidator();
|
||||
$this->synchronizer = new RepositorySynchronizer(
|
||||
$this->api,
|
||||
$this->logger,
|
||||
@@ -215,18 +211,15 @@ class BulkSync extends CLIApp
|
||||
);
|
||||
|
||||
// Initialize plugin system
|
||||
$this->pluginFactory = new PluginFactory($this->logger, $this->metrics);
|
||||
$this->typeDetector = new ProjectTypeDetector($this->logger);
|
||||
|
||||
$this->log("✓ Enterprise components initialized for platform: {$platform}", 'INFO');
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->log("❌ Failed to initialize: " . $e->getMessage(), 'ERROR');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse repository list from string
|
||||
*/
|
||||
@@ -235,13 +228,13 @@ class BulkSync extends CLIApp
|
||||
if (empty($input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
return array_filter(
|
||||
array_map('trim', preg_split('/[\s,]+/', $input)),
|
||||
fn($r) => !empty($r)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Filter repositories based on include/exclude lists
|
||||
*/
|
||||
@@ -289,7 +282,7 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_merge($priority, $rest));
|
||||
return array_merge($priority, $rest);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,11 +293,11 @@ class BulkSync extends CLIApp
|
||||
if ($this->quiet) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
echo "\n⚠️ About to synchronize {$count} repositories.\n";
|
||||
echo "This will update files across all repositories.\n";
|
||||
echo "\nContinue? [y/N]: ";
|
||||
|
||||
|
||||
$handle = fopen("php://stdin", "r");
|
||||
$line = fgets($handle);
|
||||
if ($handle) {
|
||||
@@ -315,7 +308,7 @@ class BulkSync extends CLIApp
|
||||
// treat that as a non-confirmation rather than crashing.
|
||||
return is_string($line) && strtolower(trim($line)) === 'y';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Execute synchronization across repositories
|
||||
*
|
||||
@@ -344,8 +337,12 @@ class BulkSync extends CLIApp
|
||||
// instead of leaving the run in an unknown state.
|
||||
if (function_exists('pcntl_async_signals')) {
|
||||
pcntl_async_signals(true);
|
||||
pcntl_signal(SIGINT, function () { $this->interrupted = true; });
|
||||
pcntl_signal(SIGTERM, function () { $this->interrupted = true; });
|
||||
pcntl_signal(SIGINT, function () {
|
||||
$this->interrupted = true;
|
||||
});
|
||||
pcntl_signal(SIGTERM, function () {
|
||||
$this->interrupted = true;
|
||||
});
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
@@ -410,7 +407,6 @@ class BulkSync extends CLIApp
|
||||
$results['repositories'][$repoName] = 'skipped';
|
||||
$this->log(" ⊘ {$repoName} skipped", 'INFO');
|
||||
}
|
||||
|
||||
} catch (SynchronizationNotImplementedException $e) {
|
||||
$this->log("", 'ERROR');
|
||||
$this->log("╔══════════════════════════════════════════════════════════════════════════╗", 'ERROR');
|
||||
@@ -420,7 +416,7 @@ class BulkSync extends CLIApp
|
||||
$this->log("The bulk repository sync is failing silently because the core", 'ERROR');
|
||||
$this->log("synchronization logic has not been implemented yet.", 'ERROR');
|
||||
$this->log("", 'ERROR');
|
||||
$this->log("Location: api/lib/Enterprise/RepositorySynchronizer.php", 'ERROR');
|
||||
$this->log("Location: lib/Enterprise/RepositorySynchronizer.php", 'ERROR');
|
||||
$this->log("Method: processRepository()", 'ERROR');
|
||||
$this->log("", 'ERROR');
|
||||
$this->log("Required Implementation:", 'ERROR');
|
||||
@@ -432,12 +428,10 @@ class BulkSync extends CLIApp
|
||||
$this->log("Until this is implemented, bulk sync will not function.", 'ERROR');
|
||||
$this->log("", 'ERROR');
|
||||
throw $e;
|
||||
|
||||
} catch (CircuitBreakerOpen $e) {
|
||||
$results['failed']++;
|
||||
$results['repositories'][$repoName] = 'failed';
|
||||
$this->log(" ✗ {$repoName} failed: Circuit breaker open - " . $e->getMessage(), 'ERROR');
|
||||
|
||||
} catch (RateLimitExceeded $e) {
|
||||
// Rate limit hit — abort immediately so we don't burn retries on 403s
|
||||
$results['failed']++;
|
||||
@@ -445,7 +439,6 @@ class BulkSync extends CLIApp
|
||||
$this->log(" ✗ {$repoName} rate-limited: " . $e->getMessage(), 'ERROR');
|
||||
$this->saveInterruptCheckpoint($results, $repoName, 'rate_limited');
|
||||
break;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Also catch rate limits surfaced as generic exceptions by ApiClient retries
|
||||
if ($this->isRateLimitError($e)) {
|
||||
@@ -509,12 +502,12 @@ class BulkSync extends CLIApp
|
||||
]);
|
||||
$script = basename(__FILE__);
|
||||
$this->log("💾 Checkpoint saved. To resume once the issue is resolved, run:", 'INFO');
|
||||
$this->log(" php api/automation/{$script} --resume [same flags as before]", 'INFO');
|
||||
$this->log(" php automation/{$script} --resume [same flags as before]", 'INFO');
|
||||
} catch (\Exception $e) {
|
||||
$this->log("⚠️ Failed to save interrupt checkpoint: " . $e->getMessage(), 'WARN');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Display synchronization results
|
||||
*/
|
||||
@@ -523,22 +516,22 @@ class BulkSync extends CLIApp
|
||||
$this->log("\n" . str_repeat('=', 60), 'INFO');
|
||||
$this->log("📊 Synchronization Complete", 'INFO');
|
||||
$this->log(str_repeat('=', 60), 'INFO');
|
||||
|
||||
|
||||
$total = $results['total'];
|
||||
$success = $results['success'];
|
||||
$skipped = $results['skipped'];
|
||||
$failed = $results['failed'];
|
||||
$duration = $results['duration'];
|
||||
|
||||
|
||||
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
|
||||
|
||||
|
||||
$this->log(sprintf("Total: %d repositories", $total), 'INFO');
|
||||
$this->log(sprintf("Success: %d (✓)", $success), 'INFO');
|
||||
$this->log(sprintf("Skipped: %d (⊘)", $skipped), 'INFO');
|
||||
$this->log(sprintf("Failed: %d (✗)", $failed), 'INFO');
|
||||
$this->log(sprintf("Success Rate: %.1f%%", $successRate), 'INFO');
|
||||
$this->log(sprintf("Duration: %.2f seconds", $duration), 'INFO');
|
||||
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->log("\n⚠️ Failed Repositories:", 'WARN');
|
||||
foreach ($results['repositories'] as $repo => $status) {
|
||||
@@ -547,11 +540,11 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ($this->verbose) {
|
||||
$this->log("\n📋 Repository Details:", 'INFO');
|
||||
foreach ($results['repositories'] as $repo => $status) {
|
||||
$icon = match($status) {
|
||||
$icon = match ($status) {
|
||||
'success' => '✓',
|
||||
'skipped' => '⊘',
|
||||
'failed' => '✗',
|
||||
@@ -560,12 +553,12 @@ class BulkSync extends CLIApp
|
||||
$this->log(sprintf(" %s %s: %s", $icon, $repo, $status), 'INFO');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->log(str_repeat('=', 60), 'INFO');
|
||||
|
||||
|
||||
$this->writeStepSummary($results);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Write synchronization results to the GitHub Actions step summary.
|
||||
*
|
||||
@@ -588,7 +581,7 @@ class BulkSync extends CLIApp
|
||||
if (empty($summaryFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Validate that the path is an absolute filesystem path and not a
|
||||
// special device file, to guard against environment variable injection.
|
||||
$realDir = realpath(dirname($summaryFile));
|
||||
@@ -596,14 +589,14 @@ class BulkSync extends CLIApp
|
||||
$this->log('⚠️ GITHUB_STEP_SUMMARY path is not safe, skipping step summary write.', 'WARN');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$total = $results['total'];
|
||||
$success = $results['success'];
|
||||
$skipped = $results['skipped'];
|
||||
$failed = $results['failed'];
|
||||
$duration = $results['duration'];
|
||||
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
|
||||
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '';
|
||||
$lines[] = '### 📊 Synchronization Summary';
|
||||
@@ -620,7 +613,7 @@ class BulkSync extends CLIApp
|
||||
$duration
|
||||
);
|
||||
$lines[] = '';
|
||||
|
||||
|
||||
if (!empty($results['repositories'])) {
|
||||
$lines[] = '### 📋 Repositories Processed';
|
||||
$lines[] = '';
|
||||
@@ -637,7 +630,7 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
|
||||
$written = file_put_contents($summaryFile, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
if ($written === false) {
|
||||
$this->log('⚠️ Failed to write to GITHUB_STEP_SUMMARY.', 'WARN');
|
||||
@@ -737,8 +730,10 @@ class BulkSync extends CLIApp
|
||||
if (str_contains($protName, 'version') || $this->refsContain($refs, 'version')) {
|
||||
$hasVersion = true;
|
||||
}
|
||||
if ((str_contains($protName, 'dev') && !str_contains($protName, 'develop'))
|
||||
|| $this->refsContain($refs, 'dev')) {
|
||||
if (
|
||||
(str_contains($protName, 'dev') && !str_contains($protName, 'develop'))
|
||||
|| $this->refsContain($refs, 'dev')
|
||||
) {
|
||||
$hasDev = true;
|
||||
}
|
||||
if (str_contains($protName, 'rc') || $this->refsContain($refs, 'rc/')) {
|
||||
@@ -746,10 +741,18 @@ class BulkSync extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasMain) { $score += 5; }
|
||||
if ($hasVersion) { $score += 5; }
|
||||
if ($hasDev) { $score += 5; }
|
||||
if ($hasRc) { $score += 5; }
|
||||
if ($hasMain) {
|
||||
$score += 5;
|
||||
}
|
||||
if ($hasVersion) {
|
||||
$score += 5;
|
||||
}
|
||||
if ($hasDev) {
|
||||
$score += 5;
|
||||
}
|
||||
if ($hasRc) {
|
||||
$score += 5;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
@@ -757,7 +760,9 @@ class BulkSync extends CLIApp
|
||||
// 2. Check branch protection on main (10 pts)
|
||||
$max += 10;
|
||||
$hasMainProtection = $this->checkBranchProtected($org, $name);
|
||||
if ($hasMainProtection) { $score += 10; }
|
||||
if ($hasMainProtection) {
|
||||
$score += 10;
|
||||
}
|
||||
|
||||
// Calculate level
|
||||
$pct = $max > 0 ? ($score / $max * 100) : 0;
|
||||
@@ -783,7 +788,10 @@ class BulkSync extends CLIApp
|
||||
$poor = count(array_filter($health, fn($h) => $h['level'] === 'poor'));
|
||||
$this->log(sprintf(
|
||||
"🩺 Health: %d excellent, %d good, %d fair, %d poor",
|
||||
$excellent, $good, $fair, $poor
|
||||
$excellent,
|
||||
$good,
|
||||
$fair,
|
||||
$poor
|
||||
), 'INFO');
|
||||
|
||||
return $health;
|
||||
@@ -1018,7 +1026,9 @@ class BulkSync extends CLIApp
|
||||
try {
|
||||
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
|
||||
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
|
||||
} catch (\Exception $e) { /* fallback to main */ }
|
||||
} catch (\Exception $e) {
|
||||
/* fallback to main */
|
||||
}
|
||||
|
||||
$prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [
|
||||
'state' => 'open',
|
||||
@@ -1048,7 +1058,7 @@ class BulkSync extends CLIApp
|
||||
if (str_contains($msg, '409') || str_contains($msg, 'Merge conflict')) {
|
||||
$this->log(" ⚠️ Merge conflict: {$defaultBranch} → {$branch} (PR #{$prNum})", 'WARN');
|
||||
} elseif (str_contains($msg, '204') || str_contains($msg, 'nothing to merge')) {
|
||||
// Already up to date — silently skip
|
||||
$this->log(" ✓ Already up to date: {$branch}", 'DEBUG');
|
||||
} else {
|
||||
$this->log(" ⚠️ Could not merge into {$branch}: " . $msg, 'WARN');
|
||||
}
|
||||
@@ -1147,6 +1157,7 @@ class BulkSync extends CLIApp
|
||||
'sort' => 'created',
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
$existing = array_values($existing);
|
||||
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
@@ -1158,7 +1169,9 @@ class BulkSync extends CLIApp
|
||||
// Re-apply labels in case any were removed
|
||||
try {
|
||||
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
|
||||
} else {
|
||||
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
|
||||
@@ -1182,7 +1195,9 @@ class BulkSync extends CLIApp
|
||||
'body' => $closeRef . "\n\n" . $currentBody,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
return is_int($num) ? $num : null;
|
||||
@@ -1286,11 +1301,12 @@ class BulkSync extends CLIApp
|
||||
'state' => 'all',
|
||||
'per_page' => 1,
|
||||
'sort' => 'created',
|
||||
'direction'=> 'desc',
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation'];
|
||||
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames);
|
||||
$existing = array_values($existing);
|
||||
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$issueNumber = $existing[0]['number'];
|
||||
@@ -1301,7 +1317,9 @@ class BulkSync extends CLIApp
|
||||
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch);
|
||||
try {
|
||||
$this->api->post("/repos/{$org}/MokoStandards/issues/{$issueNumber}/labels", ['labels' => $labels]);
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
$this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO');
|
||||
} else {
|
||||
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
||||
@@ -1355,7 +1373,7 @@ class BulkSync extends CLIApp
|
||||
|
||||
1. Check the local audit log or re-run with `--repos=<repo>` to see the specific error.
|
||||
2. Fix the underlying issue (API token, rate limit, branch protection, etc.).
|
||||
3. Re-run: `php api/automation/bulk_sync.php --org={$org} --repos=<repo> --force --yes`
|
||||
3. Re-run: `php automation/bulk_sync.php --org={$org} --repos=<repo> --force --yes`
|
||||
4. Close this issue once all repos are synced successfully.
|
||||
|
||||
---
|
||||
@@ -1372,6 +1390,7 @@ class BulkSync extends CLIApp
|
||||
'sort' => 'created',
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
$existing = array_values($existing);
|
||||
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
@@ -1399,10 +1418,6 @@ class BulkSync extends CLIApp
|
||||
|
||||
// Execute if run directly
|
||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
||||
$app = new BulkSync(
|
||||
'bulk-sync',
|
||||
'Enterprise-grade bulk repository synchronization',
|
||||
BulkSync::VERSION
|
||||
);
|
||||
$app = new BulkSync();
|
||||
exit($app->execute());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# BRIEF: Trigger a workflow across all client-waas repos in a Gitea org
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Usage
|
||||
# ---------------------------------------------------------------------------
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") GITEA_URL TOKEN ORG WORKFLOW [REF] [INPUTS]
|
||||
|
||||
Arguments:
|
||||
GITEA_URL Base URL of the Gitea instance (e.g. https://git.mokoconsulting.tech)
|
||||
TOKEN Gitea API token with repo/action permissions
|
||||
ORG Organisation or user that owns the repos
|
||||
WORKFLOW Workflow filename to trigger (e.g. dependency-audit.yml)
|
||||
REF Branch ref to run against (default: main)
|
||||
INPUTS Optional JSON object of workflow inputs (e.g. '{"dry_run":"true"}')
|
||||
|
||||
Example:
|
||||
$(basename "$0") https://git.mokoconsulting.tech abc123 MokoConsulting dependency-audit.yml main '{"notify":"true"}'
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ $# -lt 4 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
GITEA_URL="${1%/}"
|
||||
TOKEN="$2"
|
||||
ORG="$3"
|
||||
WORKFLOW="$4"
|
||||
REF="${5:-main}"
|
||||
INPUTS="${6:-{\}}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetch all repos in the org, paginated
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "Fetching repos for org '${ORG}' on ${GITEA_URL} ..."
|
||||
|
||||
PAGE=1
|
||||
LIMIT=50
|
||||
ALL_REPOS=""
|
||||
|
||||
while true; do
|
||||
RESPONSE=$(curl -s \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${GITEA_URL}/api/v1/orgs/${ORG}/repos?page=${PAGE}&limit=${LIMIT}")
|
||||
|
||||
# Break if empty array
|
||||
COUNT=$(echo "$RESPONSE" | jq -r 'length')
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
NAMES=$(echo "$RESPONSE" | jq -r '.[].name')
|
||||
ALL_REPOS="${ALL_REPOS}${NAMES}"$'\n'
|
||||
|
||||
if [ "$COUNT" -lt "$LIMIT" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter for client-waas repos
|
||||
# ---------------------------------------------------------------------------
|
||||
CLIENT_REPOS=$(echo "$ALL_REPOS" | grep 'client-waas' | sort || true)
|
||||
|
||||
if [ -z "$CLIENT_REPOS" ]; then
|
||||
echo "No client-waas repos found in org '${ORG}'."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TOTAL=$(echo "$CLIENT_REPOS" | wc -l | tr -d ' ')
|
||||
echo "Found ${TOTAL} client-waas repo(s). Triggering workflow '${WORKFLOW}' (ref: ${REF}) ..."
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger workflow for each repo
|
||||
# ---------------------------------------------------------------------------
|
||||
SUCCESS=0
|
||||
FAIL=0
|
||||
|
||||
while IFS= read -r REPO; do
|
||||
[ -z "$REPO" ] && continue
|
||||
|
||||
PAYLOAD=$(jq -n --arg ref "$REF" --argjson inputs "$INPUTS" '{ref: $ref, inputs: $inputs}')
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${GITEA_URL}/api/v1/repos/${ORG}/${REPO}/actions/workflows/${WORKFLOW}/dispatches")
|
||||
|
||||
if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 201 ]; then
|
||||
echo " [OK] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
echo " [FAIL] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
done <<< "$CLIENT_REPOS"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Done. Success: ${SUCCESS} | Failed: ${FAIL} | Total: ${TOTAL}"
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# enforce_tags.sh — Ensure all repos have the 5 standard release channel tags
|
||||
#
|
||||
# Standard tags: development, alpha, beta, release-candidate, stable
|
||||
# Also removes non-standard tags (keeps vXX production tags)
|
||||
#
|
||||
# Usage:
|
||||
# GA_TOKEN=xxx ./enforce_tags.sh [--dry-run] [--repos repo1,repo2]
|
||||
#
|
||||
# Called by: bulk-repo-sync.yml, infrastructure-tests/mirror-check.yml
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
ORG="${GITEA_ORG:-MokoConsulting}"
|
||||
TOKEN="${GA_TOKEN:?GA_TOKEN required}"
|
||||
DRY_RUN=false
|
||||
FILTER_REPOS=""
|
||||
|
||||
STANDARD_TAGS=("development" "alpha" "beta" "release-candidate" "stable")
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--repos) FILTER_REPOS="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
api() {
|
||||
local method="$1" path="$2" data="${3:-}"
|
||||
local args=(-sf -H "Authorization: token $TOKEN" -H "Content-Type: application/json" -X "$method")
|
||||
[[ -n "$data" ]] && args+=(-d "$data")
|
||||
curl "${args[@]}" "$GITEA_URL/api/v1$path" 2>/dev/null
|
||||
}
|
||||
|
||||
# Get repos
|
||||
REPOS=""
|
||||
for page in 1 2 3; do
|
||||
BATCH=$(api GET "/orgs/$ORG/repos?limit=50&page=$page" | python3 -c "
|
||||
import sys,json
|
||||
for r in json.load(sys.stdin):
|
||||
if not r.get(empty) and not r.get(archived):
|
||||
print(r[name])
|
||||
" 2>/dev/null)
|
||||
[[ -z "$BATCH" ]] && break
|
||||
REPOS="$REPOS $BATCH"
|
||||
done
|
||||
|
||||
# Filter if specified
|
||||
if [[ -n "$FILTER_REPOS" ]]; then
|
||||
FILTERED=""
|
||||
IFS=, read -ra FILTER_ARR <<< "$FILTER_REPOS"
|
||||
for repo in $REPOS; do
|
||||
for f in "${FILTER_ARR[@]}"; do
|
||||
[[ "$repo" == "$f" ]] && FILTERED="$FILTERED $repo"
|
||||
done
|
||||
done
|
||||
REPOS="$FILTERED"
|
||||
fi
|
||||
|
||||
TOTAL=$(echo $REPOS | wc -w)
|
||||
ADDED=0
|
||||
DELETED=0
|
||||
ERRORS=0
|
||||
|
||||
echo "Enforcing tags on $TOTAL repos (dry_run=$DRY_RUN)"
|
||||
|
||||
for repo in $REPOS; do
|
||||
TAGS=$(api GET "/repos/$ORG/$repo/tags?limit=50" | python3 -c "import sys,json; print( .join(t[name] for t in json.load(sys.stdin)))" 2>/dev/null)
|
||||
MAIN_SHA=$(api GET "/repos/$ORG/$repo/branches/main" | python3 -c "import sys,json; print(json.load(sys.stdin)[commit][id])" 2>/dev/null)
|
||||
[[ -z "$MAIN_SHA" ]] && continue
|
||||
|
||||
# Add missing standard tags
|
||||
for st in "${STANDARD_TAGS[@]}"; do
|
||||
if ! echo " $TAGS " | grep -q " $st "; then
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo " [DRY] ADD $repo: $st"
|
||||
else
|
||||
STATUS=$(api POST "/repos/$ORG/$repo/tags" "{\"tag_name\":\"$st\",\"target\":\"$MAIN_SHA\"}" | python3 -c "import sys,json; print(ok)" 2>/dev/null || echo "err")
|
||||
[[ "$STATUS" == "ok" ]] && ADDED=$((ADDED + 1)) || ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Remove non-standard tags
|
||||
for t in $TAGS; do
|
||||
IS_STD=false
|
||||
for st in "${STANDARD_TAGS[@]}"; do [[ "$t" == "$st" ]] && IS_STD=true; done
|
||||
# Keep vXX production tags
|
||||
if [[ "$t" =~ ^v[0-9]{1,3}$ ]]; then IS_STD=true; fi
|
||||
|
||||
if [[ "$IS_STD" == "false" ]]; then
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo " [DRY] DEL $repo: $t"
|
||||
else
|
||||
# Delete release first if exists
|
||||
api DELETE "/repos/$ORG/$repo/releases/tags/$t" > /dev/null 2>&1 || true
|
||||
api DELETE "/repos/$ORG/$repo/tags/$t" > /dev/null 2>&1
|
||||
DELETED=$((DELETED + 1))
|
||||
echo " DEL $repo: $t"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "Done: $ADDED added, $DELETED deleted, $ERRORS errors (dry_run=$DRY_RUN)"
|
||||
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/enrich_mokostandards_xml.php
|
||||
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
|
||||
*
|
||||
* Enrich XML .mokostandards manifests with repo-specific build, deploy, and script details.
|
||||
*
|
||||
* Runs AFTER push_mokostandards_xml.php. Clones each repo, inspects its contents,
|
||||
* and updates the manifest with discovered build/deploy/scripts config.
|
||||
*
|
||||
* Usage:
|
||||
* php automation/enrich_mokostandards_xml.php [--dry-run] [--repo NAME] [--skip NAME,NAME]
|
||||
*
|
||||
* Note: This script uses proc_open for shell commands. All arguments are escaped
|
||||
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use MokoEnterprise\MokoStandardsParser;
|
||||
|
||||
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
|
||||
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
|
||||
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
$repoFilter = null;
|
||||
$skipRepos = [];
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoFilter = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--skip' && isset($argv[$i + 1])) {
|
||||
$skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
$parser = new MokoStandardsParser();
|
||||
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
|
||||
|
||||
function safeExec(string $command, string $cwd = '.'): array
|
||||
{
|
||||
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
|
||||
if (!is_resource($proc)) {
|
||||
return [1, "proc_open failed"];
|
||||
}
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
|
||||
}
|
||||
|
||||
function rmTree(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
|
||||
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) {
|
||||
if ($file->isDir()) {
|
||||
@rmdir($file->getPathname());
|
||||
} else {
|
||||
@chmod($file->getPathname(), 0777);
|
||||
@unlink($file->getPathname());
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
function gitCmd(string $workDir, string ...$args): array
|
||||
{
|
||||
$cmd = 'git';
|
||||
foreach ($args as $a) {
|
||||
$cmd .= ' ' . escapeshellarg($a);
|
||||
}
|
||||
return safeExec($cmd, $workDir);
|
||||
}
|
||||
|
||||
function fetchRepos(string $url, string $org, string $token): array
|
||||
{
|
||||
$repos = [];
|
||||
$page = 1;
|
||||
do {
|
||||
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($code !== 200) {
|
||||
break;
|
||||
}
|
||||
$batch = json_decode($body, true);
|
||||
if (empty($batch)) {
|
||||
break;
|
||||
}
|
||||
$repos = array_merge($repos, $batch);
|
||||
$page++;
|
||||
} while (count($batch) >= 50);
|
||||
return $repos;
|
||||
}
|
||||
|
||||
function inspectRepo(string $workDir, string $platform): array
|
||||
{
|
||||
$enrichment = [];
|
||||
$build = [];
|
||||
|
||||
// Detect entry point
|
||||
if (is_dir("{$workDir}/src")) {
|
||||
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
|
||||
$c = file_get_contents($xf);
|
||||
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
|
||||
$build['entry_point'] = 'src/' . basename($xf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
|
||||
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 (isset($b['entry_point'])) {
|
||||
$build->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
|
||||
}
|
||||
if (isset($b['artifact'])) {
|
||||
$art = $dom->createElementNS($ns, 'artifact');
|
||||
foreach (['format','path','filename'] as $af) {
|
||||
if (isset($b['artifact'][$af])) {
|
||||
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
|
||||
}
|
||||
}
|
||||
$build->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);
|
||||
}
|
||||
$build->appendChild($deps);
|
||||
}
|
||||
$root->appendChild($build);
|
||||
}
|
||||
|
||||
if (!empty($enrichment['deploy'])) {
|
||||
$deploy = $dom->createElementNS($ns, 'deploy');
|
||||
foreach ($enrichment['deploy'] as $t) {
|
||||
$target = $dom->createElementNS($ns, 'target');
|
||||
$target->setAttribute('name', $t['name']);
|
||||
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
|
||||
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
|
||||
if (isset($t['method'])) {
|
||||
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
|
||||
}
|
||||
if (isset($t['branch'])) {
|
||||
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
|
||||
}
|
||||
if (isset($t['src_dir'])) {
|
||||
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
|
||||
}
|
||||
$deploy->appendChild($target);
|
||||
}
|
||||
$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();
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────
|
||||
echo "=== MokoStandards XML Manifest Enrichment ===\n";
|
||||
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
|
||||
if (!empty($skipRepos)) {
|
||||
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if (empty($token)) {
|
||||
fprintf(STDERR, "ERROR: GA_TOKEN required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$repos = fetchRepos($giteaUrl, $giteaOrg, $token);
|
||||
echo "Found " . count($repos) . " repositories\n\n";
|
||||
|
||||
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$name = $repo['name'];
|
||||
if ($repoFilter && $name !== $repoFilter) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($name, $skipRepos, true)) {
|
||||
echo " {$name} ... SKIP (excluded)\n";
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
if ($repo['archived'] ?? false) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$defaultBranch = $repo['default_branch'] ?? 'main';
|
||||
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
|
||||
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
|
||||
|
||||
echo " {$name} ... ";
|
||||
|
||||
$workDir = "{$tmpBase}/{$name}";
|
||||
@mkdir($workDir, 0755, true);
|
||||
[$ret] = safeExec(
|
||||
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
|
||||
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
|
||||
);
|
||||
if ($ret !== 0) {
|
||||
echo "FAIL (clone)\n";
|
||||
$stats['failed']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifestPath = "{$workDir}/.mokogitea/.mokostandards";
|
||||
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokostandards')) {
|
||||
echo "SKIP (no XML manifest)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingXml = file_get_contents($manifestPath);
|
||||
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
|
||||
$enrichment = inspectRepo($workDir, $platform);
|
||||
|
||||
if (!isset($enrichment['build'])) {
|
||||
$enrichment['build'] = [];
|
||||
}
|
||||
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
|
||||
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
|
||||
|
||||
$enrichedXml = enrichManifestXml($existingXml, $enrichment);
|
||||
$dc = count($enrichment['deploy'] ?? []);
|
||||
$sc = count($enrichment['scripts'] ?? []);
|
||||
$details = "deploy={$dc} scripts={$sc}";
|
||||
|
||||
if ($dryRun) {
|
||||
echo "WOULD ENRICH [{$details}]\n";
|
||||
$stats['enriched']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents($manifestPath, $enrichedXml);
|
||||
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
|
||||
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
|
||||
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
|
||||
|
||||
[$cr, $co] = gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}");
|
||||
if ($cr !== 0) {
|
||||
echo "SKIP (no diff)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
[$pr] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
|
||||
if ($pr !== 0) {
|
||||
echo "FAIL (push)\n";
|
||||
$stats['failed']++;
|
||||
} else {
|
||||
echo "ENRICHED [{$details}]\n";
|
||||
$stats['enriched']++;
|
||||
}
|
||||
|
||||
rmTree($workDir);
|
||||
}
|
||||
|
||||
@rmdir($tmpBase);
|
||||
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
|
||||
@@ -1,3 +1,14 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Index
|
||||
INGROUP: MokoStandards.Automation
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /automation/index.md
|
||||
BRIEF: Automation directory index
|
||||
-->
|
||||
|
||||
# Docs Index: /api/automation
|
||||
|
||||
## Purpose
|
||||
|
||||
+222
-218
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -9,16 +10,15 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/migrate_to_gitea.php
|
||||
* VERSION: 04.06.10
|
||||
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
|
||||
*
|
||||
* USAGE
|
||||
* php api/automation/migrate_to_gitea.php --dry-run
|
||||
* php api/automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
|
||||
* php api/automation/migrate_to_gitea.php --exclude MokoStandards --skip-archived
|
||||
* php api/automation/migrate_to_gitea.php --resume
|
||||
* php automation/migrate_to_gitea.php --dry-run
|
||||
* php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
|
||||
* php automation/migrate_to_gitea.php --exclude MokoStandards --skip-archived
|
||||
* php automation/migrate_to_gitea.php --resume
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -30,7 +30,7 @@ use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\Config;
|
||||
use MokoEnterprise\PlatformAdapterFactory;
|
||||
use MokoEnterprise\GitHubAdapter;
|
||||
use MokoEnterprise\GiteaAdapter;
|
||||
use MokoEnterprise\MokoGiteaAdapter;
|
||||
|
||||
/**
|
||||
* Gitea Migration Script
|
||||
@@ -42,254 +42,258 @@ use MokoEnterprise\GiteaAdapter;
|
||||
*/
|
||||
class MigrateToGitea extends CliFramework
|
||||
{
|
||||
private ?GitHubAdapter $github = null;
|
||||
private ?GiteaAdapter $gitea = null;
|
||||
private ?CheckpointManager $checkpoints = null;
|
||||
private ?GitHubAdapter $github = null;
|
||||
private ?MokoGiteaAdapter $gitea = null;
|
||||
private ?CheckpointManager $checkpoints = null;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Migrate repositories from GitHub to Gitea');
|
||||
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
|
||||
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
|
||||
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
|
||||
$this->addArgument('--resume', 'Resume from last checkpoint', false);
|
||||
$this->addArgument('--github-token', 'GitHub token override', '');
|
||||
$this->addArgument('--gitea-token', 'Gitea token override', '');
|
||||
}
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Migrate repositories from GitHub to Gitea');
|
||||
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
|
||||
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
|
||||
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
|
||||
$this->addArgument('--resume', 'Resume from last checkpoint', false);
|
||||
$this->addArgument('--github-token', 'GitHub token override', '');
|
||||
$this->addArgument('--gitea-token', 'Gitea token override', '');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
||||
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
|
||||
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
|
||||
$skipArchived = (bool) $this->getArgument('--skip-archived');
|
||||
$resume = (bool) $this->getArgument('--resume');
|
||||
protected function run(): int
|
||||
{
|
||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
||||
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
|
||||
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
|
||||
$skipArchived = (bool) $this->getArgument('--skip-archived');
|
||||
$resume = (bool) $this->getArgument('--resume');
|
||||
|
||||
$config = Config::load();
|
||||
$config = Config::load();
|
||||
|
||||
// Override tokens if provided
|
||||
$ghToken = (string) $this->getArgument('--github-token');
|
||||
$giteaToken = (string) $this->getArgument('--gitea-token');
|
||||
if ($ghToken !== '') { $config->set('github.token', $ghToken); }
|
||||
if ($giteaToken !== '') { $config->set('gitea.token', $giteaToken); }
|
||||
// Override tokens if provided
|
||||
$ghToken = (string) $this->getArgument('--github-token');
|
||||
$giteaToken = (string) $this->getArgument('--gitea-token');
|
||||
if ($ghToken !== '') {
|
||||
$config->set('github.token', $ghToken);
|
||||
}
|
||||
if ($giteaToken !== '') {
|
||||
$config->set('gitea.token', $giteaToken);
|
||||
}
|
||||
|
||||
// Create both adapters
|
||||
try {
|
||||
$adapters = PlatformAdapterFactory::createBoth($config);
|
||||
$this->github = $adapters['github'];
|
||||
$this->gitea = $adapters['gitea'];
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->log('ERROR', $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
// Create both adapters
|
||||
try {
|
||||
$adapters = PlatformAdapterFactory::createBoth($config);
|
||||
$this->github = $adapters['github'];
|
||||
$this->gitea = $adapters['gitea'];
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->log('ERROR', $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
|
||||
$org = $config->getString('github.organization', 'MokoConsulting');
|
||||
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
|
||||
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
|
||||
$org = $config->getString('github.organization', 'MokoConsulting');
|
||||
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
|
||||
|
||||
echo "=== Gitea Migration Tool ===\n";
|
||||
echo "Source: GitHub ({$org})\n";
|
||||
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
|
||||
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
|
||||
echo "=== Gitea Migration Tool ===\n";
|
||||
echo "Source: GitHub ({$org})\n";
|
||||
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
|
||||
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
|
||||
|
||||
// ── Phase 1: Discovery ──────────────────────────────────────────
|
||||
$this->section('Phase 1: Discovery');
|
||||
// ── Phase 1: Discovery ──────────────────────────────────────────
|
||||
$this->section('Phase 1: Discovery');
|
||||
|
||||
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
|
||||
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
|
||||
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
|
||||
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
|
||||
|
||||
// Filter repos
|
||||
if (!empty($specificRepos)) {
|
||||
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
|
||||
}
|
||||
if (!empty($excludeRepos)) {
|
||||
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
|
||||
}
|
||||
// Filter repos
|
||||
if (!empty($specificRepos)) {
|
||||
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
|
||||
}
|
||||
if (!empty($excludeRepos)) {
|
||||
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
|
||||
}
|
||||
|
||||
// Check which already exist on Gitea
|
||||
$giteaRepos = [];
|
||||
try {
|
||||
$existing = $this->gitea->listOrgRepos($giteaOrg);
|
||||
foreach ($existing as $r) {
|
||||
$giteaRepos[$r['name']] = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
|
||||
}
|
||||
// Check which already exist on Gitea
|
||||
$giteaRepos = [];
|
||||
try {
|
||||
$existing = $this->gitea->listOrgRepos($giteaOrg);
|
||||
foreach ($existing as $r) {
|
||||
$giteaRepos[$r['name']] = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
$toMigrate = [];
|
||||
$toSkip = [];
|
||||
foreach ($ghRepos as $repo) {
|
||||
$name = $repo['name'];
|
||||
if (isset($giteaRepos[$name])) {
|
||||
$toSkip[] = $name;
|
||||
} else {
|
||||
$toMigrate[] = $repo;
|
||||
}
|
||||
}
|
||||
$toMigrate = [];
|
||||
$toSkip = [];
|
||||
foreach ($ghRepos as $repo) {
|
||||
$name = $repo['name'];
|
||||
if (isset($giteaRepos[$name])) {
|
||||
$toSkip[] = $name;
|
||||
} else {
|
||||
$toMigrate[] = $repo;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\nMigration plan:\n";
|
||||
echo " Migrate: " . count($toMigrate) . " repositories\n";
|
||||
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
|
||||
if (!empty($toSkip)) {
|
||||
echo " Skipped: " . implode(', ', $toSkip) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
echo "\nMigration plan:\n";
|
||||
echo " Migrate: " . count($toMigrate) . " repositories\n";
|
||||
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
|
||||
if (!empty($toSkip)) {
|
||||
echo " Skipped: " . implode(', ', $toSkip) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if (empty($toMigrate)) {
|
||||
echo "Nothing to migrate.\n";
|
||||
return 0;
|
||||
}
|
||||
if (empty($toMigrate)) {
|
||||
echo "Nothing to migrate.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
echo "Repositories to migrate:\n";
|
||||
foreach ($toMigrate as $repo) {
|
||||
$vis = $repo['private'] ? 'private' : 'public';
|
||||
echo " - {$repo['name']} ({$vis})\n";
|
||||
}
|
||||
echo "\nDry run complete. Use without --dry-run to execute.\n";
|
||||
return 0;
|
||||
}
|
||||
if ($dryRun) {
|
||||
echo "Repositories to migrate:\n";
|
||||
foreach ($toMigrate as $repo) {
|
||||
$vis = $repo['private'] ? 'private' : 'public';
|
||||
echo " - {$repo['name']} ({$vis})\n";
|
||||
}
|
||||
echo "\nDry run complete. Use without --dry-run to execute.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Phase 2: Migrate ────────────────────────────────────────────
|
||||
$this->section('Phase 2: Migration');
|
||||
// ── Phase 2: Migrate ────────────────────────────────────────────
|
||||
$this->section('Phase 2: Migration');
|
||||
|
||||
$ghToken = $config->getString('github.token');
|
||||
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
|
||||
$ghToken = $config->getString('github.token');
|
||||
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
|
||||
|
||||
// Resume support
|
||||
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
|
||||
$startFrom = $checkpoint['last_completed'] ?? '';
|
||||
$skipUntil = !empty($startFrom);
|
||||
// Resume support
|
||||
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
|
||||
$startFrom = $checkpoint['last_completed'] ?? '';
|
||||
$skipUntil = !empty($startFrom);
|
||||
|
||||
foreach ($toMigrate as $index => $repo) {
|
||||
$name = $repo['name'];
|
||||
foreach ($toMigrate as $index => $repo) {
|
||||
$name = $repo['name'];
|
||||
|
||||
if ($skipUntil) {
|
||||
if ($name === $startFrom) {
|
||||
$skipUntil = false;
|
||||
}
|
||||
echo " Skipping {$name} (already migrated)\n";
|
||||
continue;
|
||||
}
|
||||
if ($skipUntil) {
|
||||
if ($name === $startFrom) {
|
||||
$skipUntil = false;
|
||||
}
|
||||
echo " Skipping {$name} (already migrated)\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
|
||||
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
|
||||
|
||||
try {
|
||||
// Shallow migration — copy current branch state only, no past
|
||||
// commit history. This gives every repo a clean start on Gitea.
|
||||
$this->gitea->migrateRepository([
|
||||
'clone_addr' => "https://github.com/{$org}/{$name}.git",
|
||||
'repo_name' => $name,
|
||||
'repo_owner' => $giteaOrg,
|
||||
'service' => 'github',
|
||||
'auth_token' => $ghToken,
|
||||
'mirror' => false,
|
||||
'private' => $repo['private'],
|
||||
'issues' => false,
|
||||
'labels' => true,
|
||||
'milestones' => false,
|
||||
'releases' => false,
|
||||
'pull_requests' => false,
|
||||
'wiki' => false,
|
||||
]);
|
||||
try {
|
||||
// Shallow migration — copy current branch state only, no past
|
||||
// commit history. This gives every repo a clean start on Gitea.
|
||||
$this->gitea->migrateRepository([
|
||||
'clone_addr' => "https://github.com/{$org}/{$name}.git",
|
||||
'repo_name' => $name,
|
||||
'repo_owner' => $giteaOrg,
|
||||
'service' => 'github',
|
||||
'auth_token' => $ghToken,
|
||||
'mirror' => false,
|
||||
'private' => $repo['private'],
|
||||
'issues' => false,
|
||||
'labels' => true,
|
||||
'milestones' => false,
|
||||
'releases' => false,
|
||||
'pull_requests' => false,
|
||||
'wiki' => false,
|
||||
]);
|
||||
|
||||
echo " Migrated successfully\n";
|
||||
$results['migrated'][] = $name;
|
||||
echo " Migrated successfully\n";
|
||||
$results['migrated'][] = $name;
|
||||
|
||||
// Save checkpoint after each successful migration
|
||||
$this->checkpoints->saveCheckpoint('gitea_migration', [
|
||||
'last_completed' => $name,
|
||||
'migrated' => $results['migrated'],
|
||||
'failed' => $results['failed'],
|
||||
]);
|
||||
// Save checkpoint after each successful migration
|
||||
$this->checkpoints->saveCheckpoint('gitea_migration', [
|
||||
'last_completed' => $name,
|
||||
'migrated' => $results['migrated'],
|
||||
'failed' => $results['failed'],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
echo " FAILED: " . $e->getMessage() . "\n";
|
||||
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
|
||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
echo " FAILED: " . $e->getMessage() . "\n";
|
||||
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
|
||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
// ── Phase 3: Post-migration ─────────────────────────────────────
|
||||
$this->section('Phase 3: Post-migration');
|
||||
|
||||
// ── Phase 3: Post-migration ─────────────────────────────────────
|
||||
$this->section('Phase 3: Post-migration');
|
||||
foreach ($results['migrated'] as $name) {
|
||||
echo " Post-processing {$name}...\n";
|
||||
|
||||
foreach ($results['migrated'] as $name) {
|
||||
echo " Post-processing {$name}...\n";
|
||||
try {
|
||||
// Apply topics from GitHub
|
||||
$ghTopics = $this->github->getRepoTopics($org, $name);
|
||||
if (!empty($ghTopics)) {
|
||||
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
|
||||
echo " Topics applied\n";
|
||||
}
|
||||
|
||||
try {
|
||||
// Apply topics from GitHub
|
||||
$ghTopics = $this->github->getRepoTopics($org, $name);
|
||||
if (!empty($ghTopics)) {
|
||||
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
|
||||
echo " Topics applied\n";
|
||||
}
|
||||
// Apply branch protection
|
||||
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
|
||||
'required_reviews' => 1,
|
||||
'dismiss_stale' => true,
|
||||
'block_on_rejected' => true,
|
||||
]);
|
||||
echo " Branch protection applied\n";
|
||||
} catch (\Exception $e) {
|
||||
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
|
||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply branch protection
|
||||
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
|
||||
'required_reviews' => 1,
|
||||
'dismiss_stale' => true,
|
||||
'block_on_rejected' => true,
|
||||
]);
|
||||
echo " Branch protection applied\n";
|
||||
// ── Phase 4: Verification ───────────────────────────────────────
|
||||
$this->section('Phase 4: Verification');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
|
||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
$report = "## Migration Report\n\n";
|
||||
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
|
||||
$report .= "**Source:** GitHub ({$org})\n";
|
||||
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
|
||||
|
||||
// ── Phase 4: Verification ───────────────────────────────────────
|
||||
$this->section('Phase 4: Verification');
|
||||
$report .= "### Results\n\n";
|
||||
$report .= "| Status | Count |\n|--------|-------|\n";
|
||||
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
|
||||
$report .= "| Failed | " . count($results['failed']) . " |\n";
|
||||
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
|
||||
|
||||
$report = "## Migration Report\n\n";
|
||||
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
|
||||
$report .= "**Source:** GitHub ({$org})\n";
|
||||
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
|
||||
if (!empty($results['migrated'])) {
|
||||
$report .= "### Migrated Repositories\n\n";
|
||||
foreach ($results['migrated'] as $name) {
|
||||
$report .= "- {$name}\n";
|
||||
}
|
||||
$report .= "\n";
|
||||
}
|
||||
|
||||
$report .= "### Results\n\n";
|
||||
$report .= "| Status | Count |\n|--------|-------|\n";
|
||||
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
|
||||
$report .= "| Failed | " . count($results['failed']) . " |\n";
|
||||
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
|
||||
if (!empty($results['failed'])) {
|
||||
$report .= "### Failed Repositories\n\n";
|
||||
foreach ($results['failed'] as $fail) {
|
||||
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
|
||||
}
|
||||
$report .= "\n";
|
||||
}
|
||||
|
||||
if (!empty($results['migrated'])) {
|
||||
$report .= "### Migrated Repositories\n\n";
|
||||
foreach ($results['migrated'] as $name) {
|
||||
$report .= "- {$name}\n";
|
||||
}
|
||||
$report .= "\n";
|
||||
}
|
||||
echo $report;
|
||||
|
||||
if (!empty($results['failed'])) {
|
||||
$report .= "### Failed Repositories\n\n";
|
||||
foreach ($results['failed'] as $fail) {
|
||||
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
|
||||
}
|
||||
$report .= "\n";
|
||||
}
|
||||
// Create summary issue on Gitea
|
||||
try {
|
||||
$this->gitea->createIssue(
|
||||
$giteaOrg,
|
||||
'MokoStandards',
|
||||
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
|
||||
$report,
|
||||
['labels' => ['automation', 'type: chore']]
|
||||
);
|
||||
echo "Migration report issue created on Gitea.\n";
|
||||
} catch (\Exception $e) {
|
||||
echo "Could not create report issue: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo $report;
|
||||
echo "\nMigration complete: " . count($results['migrated']) . " migrated, "
|
||||
. count($results['failed']) . " failed, "
|
||||
. count($results['skipped']) . " skipped\n";
|
||||
|
||||
// Create summary issue on Gitea
|
||||
try {
|
||||
$this->gitea->createIssue($giteaOrg, 'MokoStandards',
|
||||
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
|
||||
$report,
|
||||
['labels' => ['automation', 'type: chore']]
|
||||
);
|
||||
echo "Migration report issue created on Gitea.\n";
|
||||
} catch (\Exception $e) {
|
||||
echo "Could not create report issue: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "\nMigration complete: " . count($results['migrated']) . " migrated, "
|
||||
. count($results['failed']) . " failed, "
|
||||
. count($results['skipped']) . " skipped\n";
|
||||
|
||||
return count($results['failed']) > 0 ? 1 : 0;
|
||||
}
|
||||
return count($results['failed']) > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea');
|
||||
|
||||
+54
-42
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -10,9 +11,8 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards.Scripts
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/push_files.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Push one or more specific files to one or more remote repositories
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,7 @@ require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
use MokoEnterprise\{
|
||||
ApiClient,
|
||||
AuditLogger,
|
||||
CLIApp,
|
||||
CliFramework,
|
||||
Config,
|
||||
DefinitionParser,
|
||||
GitPlatformAdapter,
|
||||
@@ -51,32 +51,31 @@ use MokoEnterprise\{
|
||||
* php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent
|
||||
* php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct
|
||||
*/
|
||||
class PushFiles extends CLIApp
|
||||
class PushFiles extends CliFramework
|
||||
{
|
||||
public const DEFAULT_ORG = 'MokoConsulting';
|
||||
public const VERSION = '04.06.00';
|
||||
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private DefinitionParser $defParser;
|
||||
private ProjectTypeDetector $typeDetector;
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private DefinitionParser $defParser;
|
||||
private ProjectTypeDetector $typeDetector;
|
||||
|
||||
/**
|
||||
* Setup command-line arguments
|
||||
*/
|
||||
protected function setupArguments(): array
|
||||
protected function configure(): void
|
||||
{
|
||||
return [
|
||||
'org:' => 'GitHub organization (default: ' . self::DEFAULT_ORG . ')',
|
||||
'repos:' => 'Target repositories — comma or space-separated (required)',
|
||||
'files:' => 'Files to push — destination paths or source:destination pairs, comma/space-separated (required)',
|
||||
'message:' => 'Custom commit message (optional)',
|
||||
'branch:' => 'Target branch for direct pushes (default: repo default branch). Ignored unless --direct is set',
|
||||
'direct' => 'Push directly to target branch instead of creating a PR',
|
||||
'yes' => 'Auto-confirm without prompting',
|
||||
'no-issue' => 'Skip creating a tracking issue in each target repository',
|
||||
];
|
||||
$this->setDescription('Push files to remote repositories');
|
||||
$this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG);
|
||||
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
|
||||
$this->addArgument('--files', 'Files to push (comma-separated)', '');
|
||||
$this->addArgument('--message', 'Custom commit message', '');
|
||||
$this->addArgument('--branch', 'Target branch for direct pushes', '');
|
||||
$this->addArgument('--direct', 'Push directly instead of PR', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm without prompting', false);
|
||||
$this->addArgument('--no-issue', 'Skip creating tracking issue', false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,11 +89,11 @@ class PushFiles extends CLIApp
|
||||
return 1;
|
||||
}
|
||||
|
||||
$org = $this->getOption('org', self::DEFAULT_ORG);
|
||||
$reposArg = $this->getOption('repos', '');
|
||||
$filesArg = $this->getOption('files', '');
|
||||
$direct = $this->hasOption('direct');
|
||||
$autoYes = $this->hasOption('yes');
|
||||
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||
$reposArg = $this->getArgument('--repos', '');
|
||||
$filesArg = $this->getArgument('--files', '');
|
||||
$direct = $this->getArgument('--direct', false);
|
||||
$autoYes = $this->getArgument('--yes', false);
|
||||
|
||||
// Validate required arguments
|
||||
if (empty($reposArg)) {
|
||||
@@ -127,7 +126,7 @@ class PushFiles extends CLIApp
|
||||
}
|
||||
|
||||
// Confirm before proceeding
|
||||
if (!$autoYes && !$this->confirm($repoFileMaps, $direct)) {
|
||||
if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) {
|
||||
$this->log('❌ Cancelled.', 'INFO');
|
||||
return 0;
|
||||
}
|
||||
@@ -265,7 +264,8 @@ class PushFiles extends CLIApp
|
||||
// Fall back to live detection
|
||||
try {
|
||||
$repoData = $this->api->get("/repos/{$org}/{$repo}");
|
||||
return $this->typeDetector->detect($repoData, $org, $repo);
|
||||
$result = $this->typeDetector->detect('.');
|
||||
return $result['type'] ?? 'default';
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN');
|
||||
return 'default';
|
||||
@@ -277,7 +277,7 @@ class PushFiles extends CLIApp
|
||||
*
|
||||
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
|
||||
*/
|
||||
private function confirm(array $repoFileMaps, bool $direct): bool
|
||||
private function confirmPush(array $repoFileMaps, bool $direct): bool
|
||||
{
|
||||
if ($this->quiet) {
|
||||
return true;
|
||||
@@ -322,8 +322,8 @@ class PushFiles extends CLIApp
|
||||
'repos' => [],
|
||||
];
|
||||
|
||||
$customMessage = $this->getOption('message', '');
|
||||
$targetBranch = $this->getOption('branch', '');
|
||||
$customMessage = $this->getArgument('--message', '');
|
||||
$targetBranch = $this->getArgument('--branch', '');
|
||||
|
||||
foreach ($repoFileMaps as $repo => $entries) {
|
||||
$this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO');
|
||||
@@ -357,7 +357,12 @@ class PushFiles extends CLIApp
|
||||
$prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards";
|
||||
$prBody = $this->buildPRBody($entries);
|
||||
$pr = $this->adapter->createPullRequest(
|
||||
$org, $repo, $prTitle, $branch, $defaultBranch, $prBody,
|
||||
$org,
|
||||
$repo,
|
||||
$prTitle,
|
||||
$branch,
|
||||
$defaultBranch,
|
||||
$prBody,
|
||||
['assignees' => ['jmiller']]
|
||||
);
|
||||
$prNumber = $pr['number'] ?? null;
|
||||
@@ -372,7 +377,6 @@ class PushFiles extends CLIApp
|
||||
}
|
||||
|
||||
$results['success']++;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ✗ {$repo}: " . $e->getMessage(), 'ERROR');
|
||||
$results['failed']++;
|
||||
@@ -441,7 +445,13 @@ class PushFiles extends CLIApp
|
||||
|
||||
try {
|
||||
$this->adapter->createOrUpdateFile(
|
||||
$org, $repo, $destPath, $content, $message, $existingSha, $branch
|
||||
$org,
|
||||
$repo,
|
||||
$destPath,
|
||||
$content,
|
||||
$message,
|
||||
$existingSha,
|
||||
$branch
|
||||
);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
@@ -510,6 +520,7 @@ class PushFiles extends CLIApp
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
$existing = array_values($existing);
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
||||
@@ -519,7 +530,9 @@ class PushFiles extends CLIApp
|
||||
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch);
|
||||
try {
|
||||
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
|
||||
} else {
|
||||
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
|
||||
@@ -544,7 +557,9 @@ class PushFiles extends CLIApp
|
||||
'body' => $ref . "\n\n" . $currentBody,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $le) { /* non-fatal */ }
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN');
|
||||
@@ -567,7 +582,7 @@ class PushFiles extends CLIApp
|
||||
));
|
||||
|
||||
$repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos));
|
||||
$fileArgs = $this->getOption('files', '');
|
||||
$fileArgs = $this->getArgument('--files', '');
|
||||
|
||||
$title = "fix: push_files failed for {$failed} repo(s) — action required";
|
||||
|
||||
@@ -590,7 +605,7 @@ class PushFiles extends CLIApp
|
||||
|
||||
1. Check the output above for the specific error per repo.
|
||||
2. Fix the underlying issue (API token, branch permissions, file path, etc.).
|
||||
3. Re-run: `php api/automation/push_files.php --org={$org} --repos=<repo> --files=<files> --yes`
|
||||
3. Re-run: `php automation/push_files.php --org={$org} --repos=<repo> --files=<files> --yes`
|
||||
4. Close this issue once resolved.
|
||||
|
||||
---
|
||||
@@ -608,6 +623,7 @@ class PushFiles extends CLIApp
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
$existing = array_values($existing);
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
$num = $existing[0]['number'];
|
||||
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
||||
@@ -679,10 +695,6 @@ class PushFiles extends CLIApp
|
||||
|
||||
// Execute if run directly
|
||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
||||
$app = new PushFiles(
|
||||
'push-files',
|
||||
'Push one or more specific files to one or more remote repositories',
|
||||
PushFiles::VERSION
|
||||
);
|
||||
$app = new PushFiles();
|
||||
exit($app->execute());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/push_mokostandards_xml.php
|
||||
* BRIEF: Push XML manifests to all governed repositories
|
||||
*
|
||||
* Push XML .mokostandards manifest to all governed repositories.
|
||||
*
|
||||
* Uses git SSH to bypass the Gitea reverse-proxy WAF that blocks
|
||||
* API requests to paths containing ".mokogitea".
|
||||
*
|
||||
* Usage:
|
||||
* php automation/push_mokostandards_xml.php [--dry-run] [--repo NAME] [--force]
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use MokoEnterprise\MokoStandardsParser;
|
||||
|
||||
// ── Configuration ────────────────────────────────────────────────────────
|
||||
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
|
||||
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
|
||||
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
|
||||
$sshBase = 'ssh://gitea@git.mokoconsulting.tech:2222';
|
||||
|
||||
// ── CLI args ─────────────────────────────────────────────────────────────
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
$force = in_array('--force', $argv, true);
|
||||
$repoFilter = null;
|
||||
$skipRepos = [];
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoFilter = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--skip' && isset($argv[$i + 1])) {
|
||||
$skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
$parser = new MokoStandardsParser();
|
||||
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
|
||||
|
||||
// ── Platform detection heuristics (mirrors RepositorySynchronizer) ───────
|
||||
$CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
|
||||
|
||||
function detectPlatform(array $repo): string
|
||||
{
|
||||
global $CRM_PLATFORM_REPOS;
|
||||
$name = $repo['name'] ?? '';
|
||||
$nameLower = strtolower($name);
|
||||
$description = strtolower($repo['description'] ?? '');
|
||||
$topics = $repo['topics'] ?? [];
|
||||
|
||||
if (in_array($name, $CRM_PLATFORM_REPOS, true)) {
|
||||
return 'crm-platform';
|
||||
}
|
||||
if (in_array('dolibarr-platform', $topics)) {
|
||||
return 'crm-platform';
|
||||
}
|
||||
if (in_array('joomla-template', $topics)) {
|
||||
return 'joomla-template';
|
||||
}
|
||||
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
|
||||
return 'waas-component';
|
||||
}
|
||||
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
|
||||
return 'crm-module';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe shell execution — uses proc_open with explicit arguments to avoid injection.
|
||||
* @return array{int, string}
|
||||
*/
|
||||
function safeExec(string $command, string $cwd = '.'): array
|
||||
{
|
||||
$proc = proc_open(
|
||||
$command,
|
||||
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes,
|
||||
$cwd
|
||||
);
|
||||
if (!is_resource($proc)) {
|
||||
return [1, "proc_open failed for: {$command}"];
|
||||
}
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$code = proc_close($proc);
|
||||
return [$code, trim($stdout . "\n" . $stderr)];
|
||||
}
|
||||
|
||||
/** Recursively remove a directory (cross-platform). */
|
||||
function rmTree(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
|
||||
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) {
|
||||
if ($file->isDir()) {
|
||||
@rmdir($file->getPathname());
|
||||
} else {
|
||||
// Clear read-only flag (git objects on Windows)
|
||||
@chmod($file->getPathname(), 0777);
|
||||
@unlink($file->getPathname());
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a git command safely in a given working directory.
|
||||
* @return array{int, string}
|
||||
*/
|
||||
function gitCmd(string $workDir, string ...$args): array
|
||||
{
|
||||
$cmd = 'git';
|
||||
foreach ($args as $a) {
|
||||
$cmd .= ' ' . escapeshellarg($a);
|
||||
}
|
||||
return safeExec($cmd, $workDir);
|
||||
}
|
||||
|
||||
// ── Fetch all repos via API ──────────────────────────────────────────────
|
||||
function fetchRepos(string $url, string $org, string $token): array
|
||||
{
|
||||
$repos = [];
|
||||
$page = 1;
|
||||
do {
|
||||
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code !== 200) {
|
||||
fprintf(STDERR, "API error (HTTP %d) fetching repos page %d\n", $code, $page);
|
||||
break;
|
||||
}
|
||||
|
||||
$batch = json_decode($body, true);
|
||||
if (empty($batch)) {
|
||||
break;
|
||||
}
|
||||
$repos = array_merge($repos, $batch);
|
||||
$page++;
|
||||
} while (count($batch) >= 50);
|
||||
|
||||
return $repos;
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────
|
||||
echo "=== MokoStandards XML Manifest Push ===\n";
|
||||
echo "Org: {$giteaOrg}\n";
|
||||
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
|
||||
if ($repoFilter) {
|
||||
echo "Filter: {$repoFilter}\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if (empty($token)) {
|
||||
fprintf(STDERR, "ERROR: GA_TOKEN or GH_TOKEN environment variable required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$repos = fetchRepos($giteaUrl, $giteaOrg, $token);
|
||||
echo "Found " . count($repos) . " repositories\n\n";
|
||||
|
||||
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$name = $repo['name'];
|
||||
if ($repoFilter && $name !== $repoFilter) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($name, $skipRepos, true)) {
|
||||
echo " SKIP {$name} (excluded)\n";
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
if ($repo['archived'] ?? false) {
|
||||
echo " SKIP {$name} (archived)\n";
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$platform = detectPlatform($repo);
|
||||
$defaultBranch = $repo['default_branch'] ?? 'main';
|
||||
// Prefer HTTPS with token (SSH port 2222 may be blocked); fall back to SSH
|
||||
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
|
||||
// Embed token in HTTPS URL for push auth
|
||||
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
|
||||
|
||||
echo " {$name} [{$platform}] ... ";
|
||||
|
||||
// Generate XML manifest
|
||||
$xmlContent = $parser->generate([
|
||||
'name' => $name,
|
||||
'org' => $giteaOrg,
|
||||
'platform' => $platform,
|
||||
'standards_version' => '04.07.00',
|
||||
'description' => $repo['description'] ?? '',
|
||||
'license' => 'GPL-3.0-or-later',
|
||||
'topics' => $repo['topics'] ?? [],
|
||||
'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform),
|
||||
'package_type' => MokoStandardsParser::platformPackageType($platform),
|
||||
'last_synced' => date('c'),
|
||||
]);
|
||||
|
||||
if ($dryRun) {
|
||||
echo "WOULD WRITE ({$platform})\n";
|
||||
$stats['created']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clone shallow via HTTPS (token-authed)
|
||||
$workDir = "{$tmpBase}/{$name}";
|
||||
@mkdir($workDir, 0755, true);
|
||||
|
||||
[$ret, $out] = safeExec(
|
||||
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
|
||||
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
|
||||
);
|
||||
if ($ret !== 0) {
|
||||
echo "FAIL (clone)\n";
|
||||
fprintf(STDERR, " %s\n", $out);
|
||||
$stats['failed']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already XML and up-to-date
|
||||
$manifestPath = "{$workDir}/.mokogitea/.mokostandards";
|
||||
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokostandards');
|
||||
if ($existingIsXml && !$force) {
|
||||
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
|
||||
if ($existingPlatform === $platform) {
|
||||
echo "SKIP (already XML)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 .mokostandards manifest'
|
||||
: 'chore: update .mokostandards to XML format';
|
||||
if (!empty($legacyDeleted)) {
|
||||
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
|
||||
}
|
||||
|
||||
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
|
||||
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
|
||||
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
|
||||
foreach ($legacyDeleted as $lf) {
|
||||
gitCmd($workDir, 'add', $lf);
|
||||
}
|
||||
|
||||
[$commitRet, $commitOut] = gitCmd($workDir, 'commit', '-m', $commitMsg);
|
||||
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
|
||||
echo "SKIP (no changes)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
if ($commitRet !== 0) {
|
||||
echo "FAIL (commit)\n";
|
||||
fprintf(STDERR, " %s\n", $commitOut);
|
||||
$stats['failed']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
[$pushRet, $pushOut] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
|
||||
if ($pushRet !== 0) {
|
||||
echo "FAIL (push)\n";
|
||||
fprintf(STDERR, " %s\n", $pushOut);
|
||||
$stats['failed']++;
|
||||
} else {
|
||||
$action = $isNew ? 'CREATED' : 'UPDATED';
|
||||
echo "{$action}\n";
|
||||
$stats[$isNew ? 'created' : 'updated']++;
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
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";
|
||||
+117
-101
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -10,9 +11,8 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards.Scripts
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/repo_cleanup.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
|
||||
*/
|
||||
|
||||
@@ -21,7 +21,7 @@ declare(strict_types=1);
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
|
||||
|
||||
/**
|
||||
* Enterprise Repository Cleanup
|
||||
@@ -36,7 +36,7 @@ use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, GitPlatformAdapter,
|
||||
* 7. Verify and provision standard labels
|
||||
* 8. Version drift detection
|
||||
*/
|
||||
class RepoCleanup extends CLIApp
|
||||
class RepoCleanup extends CliFramework
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
|
||||
@@ -56,44 +56,36 @@ class RepoCleanup extends CLIApp
|
||||
'deploy-rs.yml',
|
||||
];
|
||||
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
private bool $dryRun = false;
|
||||
private float $startTime;
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
protected bool $dryRun = false;
|
||||
private float $startTime;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('repo-cleanup');
|
||||
$this->setDescription('Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs');
|
||||
$this->setVersion(self::VERSION);
|
||||
|
||||
$this->addOption('org', 'GitHub organization', 'MokoConsulting');
|
||||
$this->addOption('repos', 'Specific repositories (space-separated)', '');
|
||||
$this->addOption('skip-archived', 'Skip archived repositories', false);
|
||||
$this->addOption('close-issues', 'Close resolved tracking issues (merged PR = done)', false);
|
||||
$this->addOption('lock-old-issues', 'Lock issues closed >30 days', false);
|
||||
$this->addOption('clean-workflows', 'Delete cancelled/stale workflow runs', false);
|
||||
$this->addOption('clean-logs', 'Delete workflow run logs older than --log-days', false);
|
||||
$this->addOption('log-days', 'Days to keep logs (default: 30)', '30');
|
||||
$this->addOption('delete-retired', 'Delete retired workflow files from repos', false);
|
||||
$this->addOption('check-labels', 'Verify mokostandards label exists', false);
|
||||
$this->addOption('check-drift', 'Check for version drift against README.md', false);
|
||||
$this->addOption('all', 'Run all cleanup operations', false);
|
||||
$this->addOption('yes', 'Auto-confirm prompts', false);
|
||||
$this->addOption('dry-run', 'Preview changes without making them', false);
|
||||
$this->addOption('verbose', 'Show detailed output', false);
|
||||
$this->addOption('quiet', 'Suppress non-error output', false);
|
||||
$this->addOption('json', 'Output results as JSON', false);
|
||||
$this->setDescription('Enterprise repository cleanup');
|
||||
$this->addArgument('--org', 'GitHub organization', 'MokoConsulting');
|
||||
$this->addArgument('--repos', 'Specific repos (space-separated)', '');
|
||||
$this->addArgument('--skip-archived', 'Skip archived repos', false);
|
||||
$this->addArgument('--close-issues', 'Close resolved tracking issues', false);
|
||||
$this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false);
|
||||
$this->addArgument('--clean-workflows', 'Delete stale workflow runs', false);
|
||||
$this->addArgument('--clean-logs', 'Delete old workflow logs', false);
|
||||
$this->addArgument('--log-days', 'Days to keep logs', '30');
|
||||
$this->addArgument('--delete-retired', 'Delete retired workflows', false);
|
||||
$this->addArgument('--check-labels', 'Verify labels exist', false);
|
||||
$this->addArgument('--check-drift', 'Check version drift', false);
|
||||
$this->addArgument('--all', 'Run all operations', false);
|
||||
$this->addArgument('--yes', 'Auto-confirm', false);
|
||||
$this->addArgument('--json', 'Output as JSON', false);
|
||||
}
|
||||
|
||||
protected function execute(): int
|
||||
protected function run(): int
|
||||
{
|
||||
$this->startTime = microtime(true);
|
||||
$org = $this->getOption('org', 'MokoConsulting');
|
||||
$this->dryRun = (bool) $this->getOption('dry-run', false);
|
||||
$runAll = (bool) $this->getOption('all', false);
|
||||
$org = $this->getArgument('--org', 'MokoConsulting');
|
||||
$this->dryRun = (bool) $this->getArgument('--dry-run', false);
|
||||
$runAll = (bool) $this->getArgument('--all', false);
|
||||
|
||||
$config = Config::load();
|
||||
|
||||
@@ -101,24 +93,22 @@ class RepoCleanup extends CLIApp
|
||||
$this->adapter = PlatformAdapterFactory::create($config);
|
||||
$this->api = $this->adapter->getApiClient();
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to initialize platform adapter: ' . $e->getMessage());
|
||||
$this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->logger = new AuditLogger('repo_cleanup');
|
||||
$this->metrics = new MetricsCollector('repo_cleanup');
|
||||
|
||||
$this->log("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
||||
$this->log("Organization: {$org}");
|
||||
$this->log("Current sync branch: " . self::CURRENT_BRANCH);
|
||||
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
||||
$this->logMsg("Organization: {$org}");
|
||||
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
|
||||
if ($this->dryRun) {
|
||||
$this->log("⚠️ DRY RUN — no changes will be made");
|
||||
$this->logMsg("⚠️ DRY RUN — no changes will be made");
|
||||
}
|
||||
$this->log('');
|
||||
$this->logMsg('');
|
||||
|
||||
$repos = $this->fetchRepositories($org);
|
||||
$this->log("Found " . count($repos) . " repositories");
|
||||
$this->log('');
|
||||
$this->logMsg("Found " . count($repos) . " repositories");
|
||||
$this->logMsg('');
|
||||
|
||||
$results = [
|
||||
'repos_processed' => 0,
|
||||
@@ -140,7 +130,7 @@ class RepoCleanup extends CLIApp
|
||||
$name = $repo['name'];
|
||||
$num = $i + 1;
|
||||
$total = count($repos);
|
||||
$this->log("[{$num}/{$total}] {$name}");
|
||||
$this->logMsg("[{$num}/{$total}] {$name}");
|
||||
$results['repos_processed']++;
|
||||
|
||||
try {
|
||||
@@ -151,37 +141,37 @@ class RepoCleanup extends CLIApp
|
||||
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
|
||||
|
||||
// Optional: close resolved issues
|
||||
if ($runAll || $this->getOption('close-issues', false)) {
|
||||
if ($runAll || $this->getArgument('--close-issues', false)) {
|
||||
$cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: lock old closed issues
|
||||
if ($runAll || $this->getOption('lock-old-issues', false)) {
|
||||
if ($runAll || $this->getArgument('--lock-old-issues', false)) {
|
||||
$cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: delete retired workflow files
|
||||
if ($runAll || $this->getOption('delete-retired', false)) {
|
||||
if ($runAll || $this->getArgument('--delete-retired', false)) {
|
||||
$cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: clean workflow runs
|
||||
if ($runAll || $this->getOption('clean-workflows', false)) {
|
||||
if ($runAll || $this->getArgument('--clean-workflows', false)) {
|
||||
$cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: clean old logs
|
||||
if ($runAll || $this->getOption('clean-logs', false)) {
|
||||
if ($runAll || $this->getArgument('--clean-logs', false)) {
|
||||
$cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned;
|
||||
}
|
||||
|
||||
// Optional: check labels
|
||||
if ($runAll || $this->getOption('check-labels', false)) {
|
||||
if ($runAll || $this->getArgument('--check-labels', false)) {
|
||||
$this->checkLabels($org, $name, $results);
|
||||
}
|
||||
|
||||
// Optional: check version drift
|
||||
if ($runAll || $this->getOption('check-drift', false)) {
|
||||
if ($runAll || $this->getArgument('--check-drift', false)) {
|
||||
$this->checkVersionDrift($org, $name, $results);
|
||||
}
|
||||
|
||||
@@ -189,32 +179,32 @@ class RepoCleanup extends CLIApp
|
||||
$results['repos_cleaned']++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ✗ {$name}: " . $e->getMessage());
|
||||
$this->errorMsg(" ✗ {$name}: " . $e->getMessage());
|
||||
$results['errors']++;
|
||||
}
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $this->startTime, 1);
|
||||
|
||||
$this->log('');
|
||||
$this->log('============================================================');
|
||||
$this->log("🧹 Cleanup Complete ({$duration}s)");
|
||||
$this->log('============================================================');
|
||||
$this->log("Repos processed: {$results['repos_processed']}");
|
||||
$this->log("Repos with changes: {$results['repos_cleaned']}");
|
||||
$this->log("Branches deleted: {$results['branches_deleted']}");
|
||||
$this->log("PRs closed: {$results['prs_closed']}");
|
||||
$this->log("Issues closed: {$results['issues_closed']}");
|
||||
$this->log("Issues locked: {$results['issues_locked']}");
|
||||
$this->log("Retired files: {$results['retired_files']}");
|
||||
$this->log("Workflow runs: {$results['runs_deleted']}");
|
||||
$this->log("Logs cleaned: {$results['logs_deleted']}");
|
||||
$this->log("Labels missing: {$results['labels_missing']}");
|
||||
$this->log("Version drift: {$results['version_drift']}");
|
||||
$this->log("Errors: {$results['errors']}");
|
||||
$this->log('============================================================');
|
||||
$this->logMsg('');
|
||||
$this->logMsg('============================================================');
|
||||
$this->logMsg("🧹 Cleanup Complete ({$duration}s)");
|
||||
$this->logMsg('============================================================');
|
||||
$this->logMsg("Repos processed: {$results['repos_processed']}");
|
||||
$this->logMsg("Repos with changes: {$results['repos_cleaned']}");
|
||||
$this->logMsg("Branches deleted: {$results['branches_deleted']}");
|
||||
$this->logMsg("PRs closed: {$results['prs_closed']}");
|
||||
$this->logMsg("Issues closed: {$results['issues_closed']}");
|
||||
$this->logMsg("Issues locked: {$results['issues_locked']}");
|
||||
$this->logMsg("Retired files: {$results['retired_files']}");
|
||||
$this->logMsg("Workflow runs: {$results['runs_deleted']}");
|
||||
$this->logMsg("Logs cleaned: {$results['logs_deleted']}");
|
||||
$this->logMsg("Labels missing: {$results['labels_missing']}");
|
||||
$this->logMsg("Version drift: {$results['version_drift']}");
|
||||
$this->logMsg("Errors: {$results['errors']}");
|
||||
$this->logMsg('============================================================');
|
||||
|
||||
if ($this->getOption('json', false)) {
|
||||
if ($this->getArgument('--json', false)) {
|
||||
$results['duration_seconds'] = $duration;
|
||||
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
|
||||
}
|
||||
@@ -226,8 +216,8 @@ class RepoCleanup extends CLIApp
|
||||
|
||||
private function fetchRepositories(string $org): array
|
||||
{
|
||||
$specificRepos = trim((string) $this->getOption('repos', ''));
|
||||
$skipArchived = (bool) $this->getOption('skip-archived', false);
|
||||
$specificRepos = trim((string) $this->getArgument('--repos', ''));
|
||||
$skipArchived = (bool) $this->getArgument('--skip-archived', false);
|
||||
|
||||
if (!empty($specificRepos)) {
|
||||
$names = preg_split('/[\s,]+/', $specificRepos);
|
||||
@@ -264,18 +254,22 @@ class RepoCleanup extends CLIApp
|
||||
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
|
||||
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']);
|
||||
}
|
||||
$this->log(" 🔒 Closed PR #{$pr['number']} ({$name})");
|
||||
$this->logMsg(" 🔒 Closed PR #{$pr['number']} ({$name})");
|
||||
$results['prs_closed']++;
|
||||
$changed = true;
|
||||
}
|
||||
} catch (\Exception $e) { /* non-fatal */ }
|
||||
} catch (\Exception $e) {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
if (!$this->dryRun) {
|
||||
try {
|
||||
$this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}");
|
||||
} catch (\Exception $e) { continue; }
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$this->log(" 🗑️ Deleted branch: {$name}");
|
||||
$this->logMsg(" 🗑️ Deleted branch: {$name}");
|
||||
$results['branches_deleted']++;
|
||||
$changed = true;
|
||||
}
|
||||
@@ -291,7 +285,9 @@ class RepoCleanup extends CLIApp
|
||||
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
||||
'labels' => $label, 'state' => 'open', 'per_page' => 10,
|
||||
]);
|
||||
} catch (\Exception $e) { continue; }
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$num = $issue['number'] ?? 0;
|
||||
@@ -306,11 +302,13 @@ class RepoCleanup extends CLIApp
|
||||
'state' => 'closed', 'state_reason' => 'completed',
|
||||
]);
|
||||
}
|
||||
$this->log(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
|
||||
$this->logMsg(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
|
||||
$results['issues_closed']++;
|
||||
$changed = true;
|
||||
}
|
||||
} catch (\Exception $e) { /* non-fatal */ }
|
||||
} catch (\Exception $e) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,28 +324,34 @@ class RepoCleanup extends CLIApp
|
||||
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
||||
'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc',
|
||||
]);
|
||||
} catch (\Exception $e) { return false; }
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$closedAt = $issue['closed_at'] ?? '';
|
||||
$locked = $issue['locked'] ?? false;
|
||||
$num = $issue['number'] ?? 0;
|
||||
|
||||
if ($locked || $closedAt > $cutoff || $num === 0) continue;
|
||||
if ($locked || $closedAt > $cutoff || $num === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->dryRun) {
|
||||
try {
|
||||
$this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [
|
||||
'lock_reason' => 'resolved',
|
||||
]);
|
||||
} catch (\Exception $e) { continue; }
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$results['issues_locked']++;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($results['issues_locked'] > 0) {
|
||||
$this->log(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
|
||||
$this->logMsg(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
@@ -359,17 +363,21 @@ class RepoCleanup extends CLIApp
|
||||
try {
|
||||
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
|
||||
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
|
||||
} catch (\Exception $e) { /* fallback to main */ }
|
||||
} catch (\Exception $e) {
|
||||
/* fallback to main */
|
||||
}
|
||||
|
||||
// Check both workflow directories for retired workflows (supports dual-platform repos)
|
||||
$wfDirs = array_unique(['.github/workflows', '.gitea/workflows', $this->adapter->getWorkflowDir()]);
|
||||
$wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]);
|
||||
foreach (self::RETIRED_WORKFLOWS as $wf) {
|
||||
foreach ($wfDirs as $wfDir) {
|
||||
$path = "{$wfDir}/{$wf}";
|
||||
try {
|
||||
$file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
|
||||
$sha = $file['sha'] ?? '';
|
||||
if (empty($sha)) continue;
|
||||
if (empty($sha)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->dryRun) {
|
||||
$this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [
|
||||
@@ -378,7 +386,7 @@ class RepoCleanup extends CLIApp
|
||||
'branch' => $defaultBranch,
|
||||
]);
|
||||
}
|
||||
$this->log(" Deleted retired: {$wf} (from {$wfDir})");
|
||||
$this->logMsg(" Deleted retired: {$wf} (from {$wfDir})");
|
||||
$results['retired_files']++;
|
||||
$changed = true;
|
||||
} catch (\Exception $e) {
|
||||
@@ -405,13 +413,17 @@ class RepoCleanup extends CLIApp
|
||||
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}");
|
||||
$results['runs_deleted']++;
|
||||
$changed = true;
|
||||
} catch (\Exception $e) { $this->api->resetCircuitBreaker(); }
|
||||
} catch (\Exception $e) {
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) { /* non-fatal */ }
|
||||
} catch (\Exception $e) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
if ($results['runs_deleted'] > 0) {
|
||||
$this->log(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
|
||||
$this->logMsg(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
@@ -419,7 +431,7 @@ class RepoCleanup extends CLIApp
|
||||
private function cleanOldLogs(string $org, string $repo, array &$results): bool
|
||||
{
|
||||
$changed = false;
|
||||
$days = (int) $this->getOption('log-days', '30');
|
||||
$days = (int) $this->getArgument('--log-days', '30');
|
||||
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days"));
|
||||
|
||||
try {
|
||||
@@ -433,13 +445,17 @@ class RepoCleanup extends CLIApp
|
||||
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs");
|
||||
$results['logs_deleted']++;
|
||||
$changed = true;
|
||||
} catch (\Exception $e) { $this->api->resetCircuitBreaker(); }
|
||||
} catch (\Exception $e) {
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) { /* non-fatal */ }
|
||||
} catch (\Exception $e) {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
if ($results['logs_deleted'] > 0) {
|
||||
$this->log(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
|
||||
$this->logMsg(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
@@ -449,7 +465,7 @@ class RepoCleanup extends CLIApp
|
||||
try {
|
||||
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
||||
} catch (\Exception $e) {
|
||||
$this->log(" ⚠️ Missing 'mokostandards' label");
|
||||
$this->logMsg(" ⚠️ Missing 'mokostandards' label");
|
||||
$results['labels_missing']++;
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
@@ -469,7 +485,7 @@ class RepoCleanup extends CLIApp
|
||||
$mokoContent = base64_decode($mokoFile['content'] ?? '');
|
||||
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
|
||||
if ($vm[1] !== self::VERSION) {
|
||||
$this->log(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
|
||||
$this->logMsg(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
|
||||
$results['version_drift']++;
|
||||
}
|
||||
}
|
||||
@@ -484,14 +500,14 @@ class RepoCleanup extends CLIApp
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private function log(string $message): void
|
||||
private function logMsg(string $message): void
|
||||
{
|
||||
if (!$this->getOption('quiet', false)) {
|
||||
if (!$this->quiet) {
|
||||
echo $message . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function error(string $message): void
|
||||
private function errorMsg(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . "\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
#!/usr/bin/env bash
|
||||
# server-autoheal.sh - Auto-heal on restart + split backup management
|
||||
#
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# DEFGROUP: MokoStandards.Automation.ServerAutoheal
|
||||
# INGROUP: MokoStandards.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/server-autoheal.sh
|
||||
# BRIEF: Server auto-heal on unclean restart + split system/content backups
|
||||
#
|
||||
# Usage:
|
||||
# server-autoheal.sh <command> [options]
|
||||
#
|
||||
# Commands:
|
||||
# boot-check Run at boot — auto-heals if no safe point exists
|
||||
# set-safepoint Mark current state as safe (call before planned shutdown)
|
||||
# backup-system Run a system backup (configs, packages, services)
|
||||
# backup-content Run a content backup (site files, databases, uploads)
|
||||
# cleanup Prune expired backups per retention policy
|
||||
# status Show safe point and backup status
|
||||
#
|
||||
# Scheduling (cron):
|
||||
# @reboot server-autoheal.sh boot-check
|
||||
# 0 3 * * * server-autoheal.sh backup-system (daily at 3am)
|
||||
# 0 */2 * * * server-autoheal.sh backup-content (every 2 hours)
|
||||
# 30 */2 * * * server-autoheal.sh cleanup (30 min after content backup)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Configuration — override via /etc/moko/autoheal.conf
|
||||
# ──────────────────────────────────────────────
|
||||
CONF_FILE="/etc/moko/autoheal.conf"
|
||||
[[ -f "$CONF_FILE" ]] && source "$CONF_FILE"
|
||||
|
||||
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/moko}"
|
||||
SAFEPOINT_FILE="${SAFEPOINT_FILE:-/var/run/moko/safepoint}"
|
||||
LOG_FILE="${LOG_FILE:-/var/log/moko/autoheal.log}"
|
||||
LOCK_DIR="${LOCK_DIR:-/var/run/moko}"
|
||||
|
||||
# System backup: configs, package lists, service state, cron
|
||||
SYSTEM_BACKUP_DIR="${BACKUP_ROOT}/system"
|
||||
SYSTEM_BACKUP_RETAIN="${SYSTEM_BACKUP_RETAIN:-7}" # keep 7 daily system backups
|
||||
|
||||
# Content backup: web roots, databases, uploads
|
||||
CONTENT_BACKUP_DIR="${BACKUP_ROOT}/content"
|
||||
CONTENT_BACKUP_RETAIN_HOURS="${CONTENT_BACKUP_RETAIN_HOURS:-24}" # 1 day of content backups
|
||||
|
||||
# Paths to back up — override these in /etc/moko/autoheal.conf
|
||||
SYSTEM_PATHS="${SYSTEM_PATHS:-/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system}"
|
||||
CONTENT_PATHS="${CONTENT_PATHS:-/var/www}"
|
||||
DB_NAMES="${DB_NAMES:-}" # space-separated list, empty = auto-detect all
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────
|
||||
log() {
|
||||
local level="$1"; shift
|
||||
local ts
|
||||
ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
local msg="[$ts] [$level] $*"
|
||||
echo "$msg" | tee -a "$LOG_FILE" >&2
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" \
|
||||
"$LOCK_DIR" "$(dirname "$LOG_FILE")"
|
||||
}
|
||||
|
||||
acquire_lock() {
|
||||
local lockfile="${LOCK_DIR}/autoheal-${1}.lock"
|
||||
if [[ -f "$lockfile" ]]; then
|
||||
local pid
|
||||
pid=$(<"$lockfile")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
log WARN "Another $1 operation is running (PID $pid), skipping"
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$lockfile"
|
||||
fi
|
||||
echo $$ > "$lockfile"
|
||||
trap "rm -f '$lockfile'" EXIT
|
||||
}
|
||||
|
||||
timestamp() {
|
||||
date -u '+%Y%m%d_%H%M%S'
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Safe-point management
|
||||
# ──────────────────────────────────────────────
|
||||
cmd_set_safepoint() {
|
||||
ensure_dirs
|
||||
local ts
|
||||
ts=$(timestamp)
|
||||
cat > "$SAFEPOINT_FILE" <<EOF
|
||||
timestamp=$ts
|
||||
hostname=$(hostname)
|
||||
kernel=$(uname -r)
|
||||
uptime=$(uptime -s 2>/dev/null || echo "unknown")
|
||||
set_by=${SUDO_USER:-$(whoami)}
|
||||
EOF
|
||||
log INFO "Safe point set at $ts by ${SUDO_USER:-$(whoami)}"
|
||||
}
|
||||
|
||||
cmd_clear_safepoint() {
|
||||
rm -f "$SAFEPOINT_FILE"
|
||||
log INFO "Safe point cleared"
|
||||
}
|
||||
|
||||
has_safepoint() {
|
||||
[[ -f "$SAFEPOINT_FILE" ]]
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# System backup (daily)
|
||||
# ──────────────────────────────────────────────
|
||||
cmd_backup_system() {
|
||||
ensure_dirs
|
||||
acquire_lock "system-backup"
|
||||
|
||||
local ts
|
||||
ts=$(timestamp)
|
||||
local archive="${SYSTEM_BACKUP_DIR}/system_${ts}.tar.gz"
|
||||
local manifest="${SYSTEM_BACKUP_DIR}/system_${ts}.manifest"
|
||||
|
||||
log INFO "Starting system backup → $archive"
|
||||
|
||||
# Collect existing paths only
|
||||
local existing_paths=()
|
||||
for p in $SYSTEM_PATHS; do
|
||||
[[ -e "$p" ]] && existing_paths+=("$p")
|
||||
done
|
||||
|
||||
if [[ ${#existing_paths[@]} -eq 0 ]]; then
|
||||
log WARN "No system paths found to back up"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Archive configs and system files
|
||||
tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true
|
||||
|
||||
# Capture package list and service state as manifest
|
||||
{
|
||||
echo "=== PACKAGES ==="
|
||||
if command -v dpkg &>/dev/null; then
|
||||
dpkg --get-selections
|
||||
elif command -v rpm &>/dev/null; then
|
||||
rpm -qa --qf '%{NAME}\t%{VERSION}\n'
|
||||
fi
|
||||
echo ""
|
||||
echo "=== ENABLED SERVICES ==="
|
||||
if command -v systemctl &>/dev/null; then
|
||||
systemctl list-unit-files --state=enabled --no-pager 2>/dev/null || true
|
||||
fi
|
||||
echo ""
|
||||
echo "=== CRONTABS ==="
|
||||
for user_home in /var/spool/cron/crontabs/*; do
|
||||
[[ -f "$user_home" ]] && echo "--- $(basename "$user_home") ---" && cat "$user_home"
|
||||
done 2>/dev/null || true
|
||||
} > "$manifest"
|
||||
|
||||
local size
|
||||
size=$(du -sh "$archive" 2>/dev/null | cut -f1)
|
||||
log INFO "System backup complete: $archive ($size)"
|
||||
|
||||
# Prune old system backups (keep $SYSTEM_BACKUP_RETAIN)
|
||||
local count
|
||||
count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' | wc -l)
|
||||
if [[ "$count" -gt "$SYSTEM_BACKUP_RETAIN" ]]; then
|
||||
local to_remove=$((count - SYSTEM_BACKUP_RETAIN))
|
||||
find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
|
||||
| sort | head -n "$to_remove" | awk '{print $2}' \
|
||||
| while read -r f; do
|
||||
rm -f "$f" "${f%.tar.gz}.manifest"
|
||||
log INFO "Pruned old system backup: $f"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Content backup (every 2 hours)
|
||||
# ──────────────────────────────────────────────
|
||||
cmd_backup_content() {
|
||||
ensure_dirs
|
||||
acquire_lock "content-backup"
|
||||
|
||||
local ts
|
||||
ts=$(timestamp)
|
||||
local archive="${CONTENT_BACKUP_DIR}/content_${ts}.tar.gz"
|
||||
local db_dump="${CONTENT_BACKUP_DIR}/content_${ts}.sql.gz"
|
||||
|
||||
log INFO "Starting content backup → $archive"
|
||||
|
||||
# Back up web content / uploads
|
||||
local existing_paths=()
|
||||
for p in $CONTENT_PATHS; do
|
||||
[[ -e "$p" ]] && existing_paths+=("$p")
|
||||
done
|
||||
|
||||
if [[ ${#existing_paths[@]} -gt 0 ]]; then
|
||||
tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true
|
||||
local size
|
||||
size=$(du -sh "$archive" 2>/dev/null | cut -f1)
|
||||
log INFO "Content files archived: $archive ($size)"
|
||||
else
|
||||
log WARN "No content paths found to back up"
|
||||
fi
|
||||
|
||||
# Database dump
|
||||
if command -v mysqldump &>/dev/null || command -v mariadb-dump &>/dev/null; then
|
||||
local dump_cmd="mysqldump"
|
||||
command -v mariadb-dump &>/dev/null && dump_cmd="mariadb-dump"
|
||||
|
||||
local databases=()
|
||||
if [[ -n "$DB_NAMES" ]]; then
|
||||
read -ra databases <<< "$DB_NAMES"
|
||||
else
|
||||
# Auto-detect: dump all databases except system ones
|
||||
databases=($(${dump_cmd%dump} -N -e \
|
||||
"SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name NOT IN ('information_schema','performance_schema','mysql','sys')" \
|
||||
2>/dev/null | tr '\n' ' ')) || true
|
||||
fi
|
||||
|
||||
if [[ ${#databases[@]} -gt 0 ]]; then
|
||||
$dump_cmd --single-transaction --routines --triggers \
|
||||
--databases "${databases[@]}" 2>/dev/null \
|
||||
| gzip > "$db_dump"
|
||||
local db_size
|
||||
db_size=$(du -sh "$db_dump" 2>/dev/null | cut -f1)
|
||||
log INFO "Database dump complete: $db_dump ($db_size)"
|
||||
else
|
||||
log WARN "No databases found to dump"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Cleanup — prune content backups older than retention
|
||||
# ──────────────────────────────────────────────
|
||||
cmd_cleanup() {
|
||||
ensure_dirs
|
||||
local before_count after_count
|
||||
|
||||
# Content: keep only last 24 hours (1 day)
|
||||
before_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l)
|
||||
find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f \
|
||||
-mmin +$((CONTENT_BACKUP_RETAIN_HOURS * 60)) -delete 2>/dev/null || true
|
||||
after_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l)
|
||||
local removed=$((before_count - after_count))
|
||||
[[ "$removed" -gt 0 ]] && log INFO "Pruned $removed content backup(s) older than ${CONTENT_BACKUP_RETAIN_HOURS}h"
|
||||
|
||||
# System: keep N most recent (handled in backup-system, but double-check here)
|
||||
before_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f | wc -l)
|
||||
local max_system_files=$((SYSTEM_BACKUP_RETAIN * 2)) # .tar.gz + .manifest
|
||||
if [[ "$before_count" -gt "$max_system_files" ]]; then
|
||||
local excess=$((before_count - max_system_files))
|
||||
find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f -printf '%T+ %p\n' \
|
||||
| sort | head -n "$excess" | awk '{print $2}' \
|
||||
| xargs -r rm -f
|
||||
log INFO "Pruned excess system backups"
|
||||
fi
|
||||
|
||||
log INFO "Cleanup complete"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Boot check — the auto-heal entry point
|
||||
# ──────────────────────────────────────────────
|
||||
cmd_boot_check() {
|
||||
ensure_dirs
|
||||
acquire_lock "boot-check"
|
||||
|
||||
log INFO "=== Boot check started ==="
|
||||
log INFO "Hostname: $(hostname), Kernel: $(uname -r)"
|
||||
|
||||
if has_safepoint; then
|
||||
log INFO "Safe point found — server was shut down cleanly"
|
||||
log INFO "Clearing safe point for next cycle"
|
||||
cmd_clear_safepoint
|
||||
log INFO "=== Boot check passed (clean restart) ==="
|
||||
return 0
|
||||
fi
|
||||
|
||||
log WARN "NO safe point found — server restarted without clean shutdown"
|
||||
log WARN "Initiating auto-heal sequence..."
|
||||
|
||||
auto_heal
|
||||
local rc=$?
|
||||
|
||||
# Set safe point after successful heal
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
cmd_set_safepoint
|
||||
log INFO "=== Boot check complete (healed successfully) ==="
|
||||
else
|
||||
log ERROR "=== Boot check FAILED — manual intervention required ==="
|
||||
fi
|
||||
|
||||
return $rc
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Auto-heal strategy
|
||||
#
|
||||
# TODO: This is the core decision point. Implement the recovery
|
||||
# steps that match your server's architecture. See guidance below.
|
||||
#
|
||||
# Trade-offs to consider:
|
||||
# - Restore-from-backup: safest, but content may be up to 2h stale
|
||||
# - Service-restart-only: faster, keeps current data, but won't fix
|
||||
# corrupted configs or broken filesystem state
|
||||
# - Hybrid: restart services first, verify health, only restore if
|
||||
# health checks fail — best of both worlds but more complex
|
||||
#
|
||||
# The function receives no arguments. Use the latest system + content
|
||||
# backups to restore if needed. Return 0 on success, 1 on failure.
|
||||
# ──────────────────────────────────────────────
|
||||
auto_heal() {
|
||||
log INFO "Phase 1: Verify and repair filesystem"
|
||||
# Check for common post-crash issues
|
||||
repair_filesystem
|
||||
|
||||
log INFO "Phase 2: Restore system configuration if corrupted"
|
||||
restore_system_if_needed
|
||||
|
||||
log INFO "Phase 3: Restart core services"
|
||||
restart_services
|
||||
|
||||
log INFO "Phase 4: Verify health"
|
||||
if ! verify_health; then
|
||||
log WARN "Health check failed after service restart — restoring from backup"
|
||||
restore_from_backup
|
||||
restart_services
|
||||
|
||||
if ! verify_health; then
|
||||
log ERROR "Health check still failing after restore — giving up"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log INFO "Auto-heal completed successfully"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Heal sub-steps
|
||||
# ──────────────────────────────────────────────
|
||||
repair_filesystem() {
|
||||
# Fix common post-crash filesystem issues
|
||||
# Clear stale PID/lock/socket files that prevent services from starting
|
||||
local stale_files=(
|
||||
/var/run/nginx.pid
|
||||
/var/run/mysqld/mysqld.pid
|
||||
/var/run/php-fpm.pid
|
||||
/var/lib/mysql/*.pid
|
||||
)
|
||||
for f in "${stale_files[@]}"; do
|
||||
for expanded in $f; do
|
||||
if [[ -f "$expanded" ]]; then
|
||||
local pid
|
||||
pid=$(<"$expanded") 2>/dev/null || true
|
||||
if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then
|
||||
rm -f "$expanded"
|
||||
log INFO "Removed stale PID file: $expanded"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Fix permissions on critical dirs that may get mangled
|
||||
[[ -d /var/run/mysqld ]] && chown mysql:mysql /var/run/mysqld 2>/dev/null || true
|
||||
[[ -d /var/lib/php/sessions ]] && chmod 1733 /var/lib/php/sessions 2>/dev/null || true
|
||||
|
||||
# Repair tmp/cache dirs
|
||||
for d in /tmp /var/tmp; do
|
||||
[[ -d "$d" ]] && chmod 1777 "$d" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
restore_system_if_needed() {
|
||||
# Find latest system backup
|
||||
local latest_system
|
||||
latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
|
||||
2>/dev/null | sort -r | head -1 | awk '{print $2}')
|
||||
|
||||
if [[ -z "$latest_system" ]]; then
|
||||
log WARN "No system backup available to verify against"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check if critical configs exist and are non-empty
|
||||
local needs_restore=false
|
||||
local critical_configs=("/etc/nginx/nginx.conf" "/etc/php" "/etc/mysql")
|
||||
|
||||
for cfg in "${critical_configs[@]}"; do
|
||||
if [[ -e "$cfg" ]]; then
|
||||
# Config exists — check if it's a file and non-empty, or a directory
|
||||
if [[ -f "$cfg" && ! -s "$cfg" ]]; then
|
||||
log WARN "Critical config is empty: $cfg"
|
||||
needs_restore=true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if $needs_restore; then
|
||||
log WARN "Restoring system config from $latest_system"
|
||||
tar -xzf "$latest_system" -C / 2>/dev/null || {
|
||||
log ERROR "System restore failed from $latest_system"
|
||||
return 1
|
||||
}
|
||||
log INFO "System config restored"
|
||||
else
|
||||
log INFO "System configs look intact — skipping restore"
|
||||
fi
|
||||
}
|
||||
|
||||
restart_services() {
|
||||
if ! command -v systemctl &>/dev/null; then
|
||||
log WARN "systemctl not available — skipping service restart"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local services=("mysql" "mariadb" "nginx" "apache2" "php-fpm" "php8.1-fpm" "php8.2-fpm" "php8.3-fpm")
|
||||
|
||||
for svc in "${services[@]}"; do
|
||||
if systemctl is-enabled "$svc" &>/dev/null; then
|
||||
log INFO "Restarting $svc..."
|
||||
systemctl restart "$svc" 2>/dev/null && \
|
||||
log INFO "$svc restarted OK" || \
|
||||
log WARN "$svc restart failed"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
verify_health() {
|
||||
local failures=0
|
||||
|
||||
# Check critical services are running
|
||||
local services=("mysql" "mariadb" "nginx" "apache2")
|
||||
for svc in "${services[@]}"; do
|
||||
if systemctl is-enabled "$svc" &>/dev/null; then
|
||||
if ! systemctl is-active "$svc" &>/dev/null; then
|
||||
log WARN "Service not running: $svc"
|
||||
((failures++))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if web server responds
|
||||
if command -v curl &>/dev/null; then
|
||||
if ! curl -sf -o /dev/null --max-time 10 "http://localhost/" 2>/dev/null; then
|
||||
log WARN "Local web server not responding"
|
||||
((failures++))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if database accepts connections
|
||||
if command -v mysqladmin &>/dev/null; then
|
||||
if ! mysqladmin ping --silent 2>/dev/null; then
|
||||
log WARN "Database not responding to ping"
|
||||
((failures++))
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ $failures -eq 0 ]]
|
||||
}
|
||||
|
||||
restore_from_backup() {
|
||||
log WARN "=== Full restore from backup ==="
|
||||
|
||||
# Restore system config
|
||||
local latest_system
|
||||
latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
|
||||
2>/dev/null | sort -r | head -1 | awk '{print $2}')
|
||||
|
||||
if [[ -n "$latest_system" ]]; then
|
||||
log INFO "Restoring system from $latest_system"
|
||||
tar -xzf "$latest_system" -C / 2>/dev/null || \
|
||||
log ERROR "System restore failed"
|
||||
fi
|
||||
|
||||
# Restore content
|
||||
local latest_content
|
||||
latest_content=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \
|
||||
2>/dev/null | sort -r | head -1 | awk '{print $2}')
|
||||
|
||||
if [[ -n "$latest_content" ]]; then
|
||||
log INFO "Restoring content from $latest_content"
|
||||
tar -xzf "$latest_content" -C / 2>/dev/null || \
|
||||
log ERROR "Content restore failed"
|
||||
fi
|
||||
|
||||
# Restore database
|
||||
local latest_db
|
||||
latest_db=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.sql.gz' -printf '%T+ %p\n' \
|
||||
2>/dev/null | sort -r | head -1 | awk '{print $2}')
|
||||
|
||||
if [[ -n "$latest_db" ]]; then
|
||||
log INFO "Restoring database from $latest_db"
|
||||
local mysql_cmd="mysql"
|
||||
command -v mariadb &>/dev/null && mysql_cmd="mariadb"
|
||||
zcat "$latest_db" | $mysql_cmd 2>/dev/null || \
|
||||
log ERROR "Database restore failed"
|
||||
fi
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Status
|
||||
# ──────────────────────────────────────────────
|
||||
cmd_status() {
|
||||
echo "=== Moko Server Auto-Heal Status ==="
|
||||
echo ""
|
||||
|
||||
# Safe point
|
||||
if has_safepoint; then
|
||||
echo "Safe point: SET"
|
||||
cat "$SAFEPOINT_FILE" | sed 's/^/ /'
|
||||
else
|
||||
echo "Safe point: NOT SET (will auto-heal on next boot)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# System backups
|
||||
echo "System backups (${SYSTEM_BACKUP_DIR}):"
|
||||
local sys_count
|
||||
sys_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' 2>/dev/null | wc -l)
|
||||
echo " Count: $sys_count (retain $SYSTEM_BACKUP_RETAIN)"
|
||||
local latest_sys
|
||||
latest_sys=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
|
||||
2>/dev/null | sort -r | head -1)
|
||||
if [[ -n "$latest_sys" ]]; then
|
||||
echo " Latest: $(echo "$latest_sys" | awk '{print $2}')"
|
||||
echo " Timestamp: $(echo "$latest_sys" | awk '{print $1}')"
|
||||
else
|
||||
echo " Latest: (none)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Content backups
|
||||
echo "Content backups (${CONTENT_BACKUP_DIR}):"
|
||||
local cnt_count
|
||||
cnt_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' 2>/dev/null | wc -l)
|
||||
echo " Count: $cnt_count (retain ${CONTENT_BACKUP_RETAIN_HOURS}h)"
|
||||
local latest_cnt
|
||||
latest_cnt=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \
|
||||
2>/dev/null | sort -r | head -1)
|
||||
if [[ -n "$latest_cnt" ]]; then
|
||||
echo " Latest: $(echo "$latest_cnt" | awk '{print $2}')"
|
||||
echo " Timestamp: $(echo "$latest_cnt" | awk '{print $1}')"
|
||||
else
|
||||
echo " Latest: (none)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Disk usage
|
||||
echo "Backup disk usage:"
|
||||
du -sh "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" 2>/dev/null | sed 's/^/ /'
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Install helper — sets up cron + systemd
|
||||
# ──────────────────────────────────────────────
|
||||
cmd_install() {
|
||||
local script_path
|
||||
script_path=$(readlink -f "$0")
|
||||
|
||||
echo "Installing Moko Auto-Heal..."
|
||||
|
||||
# Create config directory
|
||||
mkdir -p /etc/moko "$(dirname "$LOG_FILE")" "$LOCK_DIR"
|
||||
|
||||
# Write example config if none exists
|
||||
if [[ ! -f "$CONF_FILE" ]]; then
|
||||
cat > "$CONF_FILE" <<'CONF'
|
||||
# /etc/moko/autoheal.conf — Server auto-heal configuration
|
||||
# Uncomment and modify as needed
|
||||
|
||||
# BACKUP_ROOT="/var/backups/moko"
|
||||
# SAFEPOINT_FILE="/var/run/moko/safepoint"
|
||||
# LOG_FILE="/var/log/moko/autoheal.log"
|
||||
|
||||
# System backup paths (space-separated)
|
||||
# SYSTEM_PATHS="/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system"
|
||||
|
||||
# Content backup paths (space-separated)
|
||||
# CONTENT_PATHS="/var/www"
|
||||
|
||||
# Database names (space-separated, empty = auto-detect all)
|
||||
# DB_NAMES=""
|
||||
|
||||
# Retention
|
||||
# SYSTEM_BACKUP_RETAIN=7 # daily backups to keep
|
||||
# CONTENT_BACKUP_RETAIN_HOURS=24 # hours of content backups to keep
|
||||
CONF
|
||||
echo " Created config: $CONF_FILE"
|
||||
fi
|
||||
|
||||
# Install cron jobs
|
||||
local cron_file="/etc/cron.d/moko-autoheal"
|
||||
cat > "$cron_file" <<CRON
|
||||
# Moko Server Auto-Heal — managed by server-autoheal.sh install
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
# Boot check — auto-heal if no safe point
|
||||
@reboot root ${script_path} boot-check
|
||||
|
||||
# System backup — daily at 3:00 AM
|
||||
0 3 * * * root ${script_path} backup-system
|
||||
|
||||
# Content backup — every 2 hours
|
||||
0 */2 * * * root ${script_path} backup-content
|
||||
|
||||
# Cleanup expired backups — 30 min after each content backup
|
||||
30 */2 * * * root ${script_path} cleanup
|
||||
CRON
|
||||
echo " Installed cron: $cron_file"
|
||||
|
||||
# Install shutdown hook to set safe point on clean shutdown
|
||||
local shutdown_hook="/etc/systemd/system/moko-safepoint.service"
|
||||
cat > "$shutdown_hook" <<UNIT
|
||||
[Unit]
|
||||
Description=Moko Safe Point — mark clean shutdown
|
||||
DefaultDependencies=no
|
||||
Before=shutdown.target reboot.target halt.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/bin/true
|
||||
ExecStop=${script_path} set-safepoint
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable moko-safepoint.service
|
||||
echo " Installed systemd hook: $shutdown_hook"
|
||||
|
||||
echo ""
|
||||
echo "Done! Edit $CONF_FILE to configure paths for your server."
|
||||
echo "Run '${script_path} status' to verify."
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Main dispatcher
|
||||
# ──────────────────────────────────────────────
|
||||
main() {
|
||||
local cmd="${1:-help}"
|
||||
|
||||
case "$cmd" in
|
||||
boot-check) cmd_boot_check ;;
|
||||
set-safepoint) cmd_set_safepoint ;;
|
||||
clear-safepoint) cmd_clear_safepoint ;;
|
||||
backup-system) cmd_backup_system ;;
|
||||
backup-content) cmd_backup_content ;;
|
||||
cleanup) cmd_cleanup ;;
|
||||
status) cmd_status ;;
|
||||
install) cmd_install ;;
|
||||
help|--help|-h)
|
||||
sed -n '2,/^$/s/^# //p' "$0"
|
||||
echo ""
|
||||
echo "Commands: boot-check, set-safepoint, clear-safepoint,"
|
||||
echo " backup-system, backup-content, cleanup, status, install"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $cmd" >&2
|
||||
echo "Run '$0 help' for usage" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -10,9 +10,8 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /bin/moko
|
||||
* VERSION: 04.00.15
|
||||
* BRIEF: Unified CLI dispatcher — run any MokoStandards script without needing GitHub Actions
|
||||
*
|
||||
* USAGE
|
||||
@@ -69,6 +68,11 @@ declare(strict_types=1);
|
||||
$repoRoot = dirname(__DIR__);
|
||||
$autoloader = $repoRoot . '/vendor/autoload.php';
|
||||
|
||||
// Support global Composer installs (e.g. composer global require)
|
||||
if (isset($GLOBALS['_composer_autoload_path'])) {
|
||||
$autoloader = $GLOBALS['_composer_autoload_path'];
|
||||
}
|
||||
|
||||
if (!is_file($autoloader)) {
|
||||
fwrite(STDERR, "Error: vendor/autoload.php not found.\nRun: composer install\n");
|
||||
exit(2);
|
||||
@@ -84,45 +88,76 @@ require_once $autoloader;
|
||||
*/
|
||||
const COMMAND_MAP = [
|
||||
// Automation
|
||||
'sync' => 'api/automation/bulk_sync.php',
|
||||
'sync' => 'automation/bulk_sync.php',
|
||||
|
||||
// Maintenance
|
||||
'inventory' => 'api/maintenance/update_repo_inventory.php',
|
||||
'inventory' => 'maintenance/update_repo_inventory.php',
|
||||
|
||||
// Validation — general
|
||||
'health' => 'api/validate/check_repo_health.php',
|
||||
'check:syntax' => 'api/validate/check_php_syntax.php',
|
||||
'check:version' => 'api/validate/check_version_consistency.php',
|
||||
'check:changelog' => 'api/validate/check_changelog.php',
|
||||
'check:structure' => 'api/validate/check_structure.php',
|
||||
'check:headers' => 'api/validate/check_license_headers.php',
|
||||
'check:secrets' => 'api/validate/check_no_secrets.php',
|
||||
'check:tabs' => 'api/validate/check_tabs.php',
|
||||
'check:paths' => 'api/validate/check_paths.php',
|
||||
'check:xml' => 'api/validate/check_xml_wellformed.php',
|
||||
'check:enterprise' => 'api/validate/check_enterprise_readiness.php',
|
||||
'health' => 'validate/check_repo_health.php',
|
||||
'check:syntax' => 'validate/check_php_syntax.php',
|
||||
'check:version' => 'validate/check_version_consistency.php',
|
||||
'check:changelog' => 'validate/check_changelog.php',
|
||||
'check:structure' => 'validate/check_structure.php',
|
||||
'check:headers' => 'validate/check_license_headers.php',
|
||||
'check:secrets' => 'validate/check_no_secrets.php',
|
||||
'check:tabs' => 'validate/check_tabs.php',
|
||||
'check:paths' => 'validate/check_paths.php',
|
||||
'check:xml' => 'validate/check_xml_wellformed.php',
|
||||
'check:enterprise' => 'validate/check_enterprise_readiness.php',
|
||||
|
||||
// Validation — platform-specific
|
||||
'check:dolibarr' => 'api/validate/check_dolibarr_module.php',
|
||||
'check:joomla' => 'api/validate/check_joomla_manifest.php',
|
||||
'check:language' => 'api/validate/check_language_structure.php',
|
||||
'check:dolibarr' => 'validate/check_dolibarr_module.php',
|
||||
'check:joomla' => 'validate/check_joomla_manifest.php',
|
||||
'check:language' => 'validate/check_language_structure.php',
|
||||
'check:client' => 'validate/check_client_theme.php',
|
||||
'check:wiki' => 'validate/check_wiki_health.php',
|
||||
|
||||
// Detection
|
||||
'detect' => 'api/validate/auto_detect_platform.php',
|
||||
'detect' => 'validate/auto_detect_platform.php',
|
||||
|
||||
// Org-wide
|
||||
'drift' => 'api/validate/scan_drift.php',
|
||||
'drift' => 'validate/scan_drift.php',
|
||||
|
||||
// Release
|
||||
'release' => 'api/cli/release.php',
|
||||
'release' => 'cli/release.php',
|
||||
'release:notes' => 'cli/release_notes.php',
|
||||
'release:validate' => 'cli/release_validate.php',
|
||||
'release:cascade' => 'cli/release_cascade.php',
|
||||
'release:manage' => 'cli/release_manage.php',
|
||||
|
||||
// CLI utilities (used by workflows — centralized logic)
|
||||
'version:read' => 'api/cli/version_read.php',
|
||||
'version:bump' => 'api/cli/version_bump.php',
|
||||
'version:propagate' => 'api/maintenance/update_version_from_readme.php',
|
||||
'version:set-platform' => 'api/cli/version_set_platform.php',
|
||||
'platform:detect' => 'api/cli/platform_detect.php',
|
||||
'release:notes' => 'api/cli/release_notes.php',
|
||||
// Version management
|
||||
'version:read' => 'cli/version_read.php',
|
||||
'version:bump' => 'cli/version_bump.php',
|
||||
'version:propagate' => 'maintenance/update_version_from_readme.php',
|
||||
'version:set-platform' => 'cli/version_set_platform.php',
|
||||
|
||||
// Build & package
|
||||
'build:package' => 'cli/package_build.php',
|
||||
'build:joomla' => 'cli/joomla_build.php',
|
||||
'build:updates-xml' => 'cli/updates_xml_build.php',
|
||||
|
||||
// Platform detection
|
||||
'platform:detect' => 'cli/platform_detect.php',
|
||||
'manifest:read' => 'cli/manifest_read.php',
|
||||
|
||||
// Repository management
|
||||
'repo:create' => 'cli/create_repo.php',
|
||||
'repo:archive' => 'cli/archive_repo.php',
|
||||
'repo:scaffold-client' => 'cli/scaffold_client.php',
|
||||
'repo:provision' => 'cli/client_provision.php',
|
||||
|
||||
// Bulk operations
|
||||
'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
|
||||
'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
|
||||
'bulk:sync-rulesets' => 'cli/sync_rulesets.php',
|
||||
|
||||
// Monitoring & dashboards
|
||||
'dashboard' => 'cli/client_dashboard.php',
|
||||
'grafana' => 'cli/grafana_dashboard.php',
|
||||
'client:inventory' => 'cli/client_inventory.php',
|
||||
|
||||
// Module validation
|
||||
'validate:module' => 'bin/validate-module',
|
||||
];
|
||||
|
||||
@@ -206,24 +241,112 @@ function printCommandList(): void
|
||||
{
|
||||
echo "Available commands:\n\n";
|
||||
|
||||
$groups = [
|
||||
'Automation' => ['sync'],
|
||||
'Maintenance' => ['inventory'],
|
||||
'Validation (general)' => ['health', 'check:syntax', 'check:version', 'check:changelog',
|
||||
'check:structure', 'check:headers', 'check:secrets',
|
||||
'check:tabs', 'check:paths', 'check:xml', 'check:enterprise'],
|
||||
'Validation (platform)' => ['check:dolibarr', 'check:joomla', 'check:language', 'detect'],
|
||||
'Organisation-wide' => ['drift'],
|
||||
];
|
||||
// Auto-group by command prefix or comment-based sections
|
||||
$groups = [];
|
||||
foreach (COMMAND_MAP as $cmd => $path) {
|
||||
if (str_contains($cmd, ':')) {
|
||||
$prefix = explode(':', $cmd)[0];
|
||||
$groupName = match ($prefix) {
|
||||
'check' => 'Validation',
|
||||
'version' => 'Version',
|
||||
'release' => 'Release',
|
||||
'build' => 'Build',
|
||||
'platform', 'manifest' => 'Platform',
|
||||
'repo' => 'Repository',
|
||||
'bulk' => 'Bulk Operations',
|
||||
'client' => 'Client Management',
|
||||
'validate' => 'Module Validation',
|
||||
default => ucfirst($prefix),
|
||||
};
|
||||
} else {
|
||||
$groupName = match ($cmd) {
|
||||
'sync' => 'Automation',
|
||||
'inventory' => 'Maintenance',
|
||||
'health' => 'Validation',
|
||||
'detect', 'drift' => 'Validation',
|
||||
'dashboard', 'grafana' => 'Monitoring',
|
||||
default => 'Other',
|
||||
};
|
||||
}
|
||||
$groups[$groupName][$cmd] = $path;
|
||||
}
|
||||
|
||||
// Load plugin commands
|
||||
$pluginCommands = loadPluginCommands();
|
||||
if (!empty($pluginCommands)) {
|
||||
foreach ($pluginCommands as $cmd => $info) {
|
||||
$type = $info['plugin'] ?? 'Plugin';
|
||||
$groups["Plugin: {$type}"][$cmd] = $info['description'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
foreach ($groups as $group => $commands) {
|
||||
echo " {$group}:\n";
|
||||
foreach ($commands as $cmd) {
|
||||
printf(" %-22s %s\n", $cmd, COMMAND_MAP[$cmd]);
|
||||
echo " \033[1m{$group}\033[0m\n";
|
||||
ksort($commands);
|
||||
foreach ($commands as $cmd => $path) {
|
||||
printf(" \033[36m%-26s\033[0m %s\n", $cmd, basename($path));
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo "Run: php bin/moko <command> --help for command-specific options.\n";
|
||||
echo "All platforms: php bin/moko <command>\n";
|
||||
$total = count(COMMAND_MAP) + count($pluginCommands);
|
||||
echo "{$total} command(s) available.\n";
|
||||
echo "Run: php bin/moko <command> --help\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Load commands from registered plugins.
|
||||
*
|
||||
* @return array<string, array{plugin: string, description: string, script: string}>
|
||||
*/
|
||||
function loadPluginCommands(): array
|
||||
{
|
||||
$pluginDir = dirname(__DIR__) . '/lib/Enterprise/Plugins';
|
||||
if (!is_dir($pluginDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$commands = [];
|
||||
|
||||
foreach (glob("{$pluginDir}/*Plugin.php") as $file) {
|
||||
$className = 'MokoEnterprise\\Plugins\\'
|
||||
. pathinfo($file, PATHINFO_FILENAME);
|
||||
|
||||
if (!class_exists($className)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$ref = new \ReflectionClass($className);
|
||||
if ($ref->isAbstract()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$plugin = $ref->newInstanceWithoutConstructor();
|
||||
$pluginCmds = $plugin->getCommands();
|
||||
|
||||
foreach ($pluginCmds as $cmd) {
|
||||
$name = $cmd['name'] ?? '';
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = method_exists($plugin, 'getProjectType')
|
||||
? $plugin->getProjectType() : 'unknown';
|
||||
|
||||
$commands[$name] = [
|
||||
'plugin' => $type,
|
||||
'description' => $cmd['description'] ?? '',
|
||||
'script' => $cmd['script'] ?? '',
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Skip plugins that can't be instantiated
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $commands;
|
||||
}
|
||||
|
||||
@@ -7,17 +7,16 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/archive_repo.php
|
||||
* VERSION: 04.06.10
|
||||
* BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def
|
||||
*
|
||||
* USAGE
|
||||
* php api/cli/archive_repo.php --repo MokoOldModule
|
||||
* php api/cli/archive_repo.php --repo MokoOldModule --dry-run
|
||||
* php api/cli/archive_repo.php --repo MokoOldModule --skip-close # Archive only, keep issues open
|
||||
* 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);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/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/badge_update.php
|
||||
* BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files
|
||||
*
|
||||
* Usage:
|
||||
* php badge_update.php --path /repo --version 04.01.00
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
|
||||
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) {
|
||||
fwrite(STDERR, "Usage: badge_update.php --path . --version XX.YY.ZZ\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/';
|
||||
$replacement = "[VERSION: {$version}]";
|
||||
$updated = 0;
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
$filePath = $file->getPathname();
|
||||
|
||||
// Skip .git and vendor directories
|
||||
if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process markdown files
|
||||
if (!preg_match('/\.md$/i', $filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if (preg_match($pattern, $content)) {
|
||||
$newContent = preg_replace($pattern, $replacement, $content);
|
||||
if ($newContent !== $content) {
|
||||
file_put_contents($filePath, $newContent);
|
||||
$relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
|
||||
echo "Updated: {$relative}\n";
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "Updated {$updated} file(s) to {$replacement}\n";
|
||||
exit(0);
|
||||
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/bulk_workflow_push.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class BulkWorkflowPush
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private string $org = '';
|
||||
private string $workflowFile = '';
|
||||
private string $destPath = '';
|
||||
private string $branch = 'main';
|
||||
private bool $dryRun = false;
|
||||
|
||||
private int $updated = 0;
|
||||
private int $created = 0;
|
||||
private int $skipped = 0;
|
||||
private int $errors = 0;
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->log('ERROR: --token is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->workflowFile === '') {
|
||||
$this->log('ERROR: --file is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file_exists($this->workflowFile)) {
|
||||
$this->log("ERROR: File not found: {$this->workflowFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->org === '') {
|
||||
$this->log('ERROR: --org is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->destPath === '') {
|
||||
$this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile);
|
||||
}
|
||||
|
||||
$localContent = file_get_contents($this->workflowFile);
|
||||
|
||||
if ($localContent === false) {
|
||||
$this->log("ERROR: Could not read file: {$this->workflowFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Pushing: {$this->workflowFile}");
|
||||
$this->log(" -> {$this->destPath} (branch: {$this->branch})");
|
||||
$this->log(" -> Org: {$this->org} @ {$this->giteaUrl}");
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log('[DRY RUN] No changes will be made.');
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
|
||||
$repos = $this->fetchOrgRepos();
|
||||
|
||||
if ($repos === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\".");
|
||||
$this->log('');
|
||||
$this->log(sprintf('%-45s | %s', 'Repo', 'Status'));
|
||||
$this->log(str_repeat('-', 70));
|
||||
|
||||
$encodedContent = base64_encode($localContent);
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$this->pushToRepo($repo, $encodedContent, $localContent);
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log("Done: {$this->created} created, {$this->updated} updated, "
|
||||
. "{$this->skipped} skipped, {$this->errors} error(s).");
|
||||
|
||||
return $this->errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function pushToRepo(
|
||||
string $repoFullName,
|
||||
string $encodedContent,
|
||||
string $localContent
|
||||
): void {
|
||||
[$owner, $repoName] = explode('/', $repoFullName, 2);
|
||||
|
||||
$existing = $this->apiRequest(
|
||||
'GET',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||
. "{$this->destPath}?ref={$this->branch}"
|
||||
);
|
||||
|
||||
if ($existing['code'] === 200) {
|
||||
$data = json_decode($existing['body'], true);
|
||||
$remoteSha = $data['sha'] ?? '';
|
||||
$remoteContent = base64_decode($data['content'] ?? '');
|
||||
|
||||
if ($remoteContent === $localContent) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'IDENTICAL (skipped)'
|
||||
));
|
||||
$this->skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'WOULD UPDATE'
|
||||
));
|
||||
$this->updated++;
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => $encodedContent,
|
||||
'sha' => $remoteSha,
|
||||
'message' => "chore: sync {$this->destPath} "
|
||||
. "from moko-platform [skip ci]",
|
||||
'branch' => $this->branch,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'PUT',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||
. $this->destPath,
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'UPDATED'
|
||||
));
|
||||
$this->updated++;
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
"ERROR (HTTP {$response['code']})"
|
||||
));
|
||||
$this->errors++;
|
||||
}
|
||||
} elseif ($existing['code'] === 404) {
|
||||
if ($this->dryRun) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'WOULD CREATE'
|
||||
));
|
||||
$this->created++;
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => $encodedContent,
|
||||
'message' => "chore: add {$this->destPath} "
|
||||
. "from moko-platform [skip ci]",
|
||||
'branch' => $this->branch,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||
. $this->destPath,
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 201) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'CREATED'
|
||||
));
|
||||
$this->created++;
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
"ERROR (HTTP {$response['code']})"
|
||||
));
|
||||
$this->errors++;
|
||||
}
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
"ERROR (HTTP {$existing['code']})"
|
||||
));
|
||||
$this->errors++;
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchOrgRepos(): ?array
|
||||
{
|
||||
$this->log("Fetching repos from org: {$this->org}");
|
||||
|
||||
$page = 1;
|
||||
$repos = [];
|
||||
|
||||
while (true) {
|
||||
$response = $this->apiRequest(
|
||||
'GET',
|
||||
"/api/v1/orgs/{$this->org}/repos?"
|
||||
. "limit=50&page={$page}"
|
||||
);
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||
if ($page === 1) {
|
||||
$this->log("ERROR: Could not fetch repos "
|
||||
. "(HTTP {$response['code']}).");
|
||||
return null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($data) || count($data) === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($data as $repo) {
|
||||
if (!empty($repo['archived'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullName = $repo['full_name'] ?? '';
|
||||
|
||||
if ($fullName !== '') {
|
||||
$repos[] = $fullName;
|
||||
}
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
|
||||
return $repos;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--file':
|
||||
$this->workflowFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dest':
|
||||
$this->destPath = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--branch':
|
||||
$this->branch = $args[++$i] ?? 'main';
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown argument: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log(
|
||||
'Usage: bulk_workflow_push.php '
|
||||
. '--token <token> --file <path> --org <org> [options]'
|
||||
);
|
||||
$this->log('');
|
||||
$this->log(
|
||||
'Push a workflow file from moko-platform '
|
||||
. 'to all governed repos.'
|
||||
);
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --gitea-url <url> Gitea URL '
|
||||
. '(default: https://git.mokoconsulting.tech)');
|
||||
$this->log(' --token <token> Gitea API token');
|
||||
$this->log(' --org <org> Target organization');
|
||||
$this->log(' --file <path> Local workflow file to push');
|
||||
$this->log(' --dest <path> Destination path in repos '
|
||||
. '(default: .mokogitea/workflows/<filename>)');
|
||||
$this->log(' --branch <branch> Target branch (default: main)');
|
||||
$this->log(' --dry-run Show what would be done');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function apiRequest(
|
||||
string $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 BulkWorkflowPush();
|
||||
exit($app->run());
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/bulk_workflow_trigger.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Trigger a workflow across multiple repos at once
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class BulkWorkflowTrigger
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private string $reposFile = '';
|
||||
private string $org = '';
|
||||
private string $workflow = '';
|
||||
private string $ref = 'main';
|
||||
private string $inputs = '';
|
||||
private bool $dryRun = false;
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->token === '')
|
||||
{
|
||||
$this->log('ERROR: --token is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->workflow === '')
|
||||
{
|
||||
$this->log('ERROR: --workflow is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->reposFile === '' && $this->org === '')
|
||||
{
|
||||
$this->log('ERROR: Either --repos <file> or --org <org> is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Build repo list
|
||||
$repos = $this->buildRepoList();
|
||||
|
||||
if ($repos === null || count($repos) === 0)
|
||||
{
|
||||
$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).");
|
||||
$this->log("Gitea URL: {$this->giteaUrl}");
|
||||
|
||||
if ($this->dryRun)
|
||||
{
|
||||
$this->log('[DRY RUN] No requests will be sent.');
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
|
||||
// Parse inputs
|
||||
$inputsDecoded = null;
|
||||
|
||||
if ($this->inputs !== '')
|
||||
{
|
||||
$inputsDecoded = json_decode($this->inputs, true);
|
||||
|
||||
if (!is_array($inputsDecoded))
|
||||
{
|
||||
$this->log('ERROR: --inputs must be valid JSON.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Print header
|
||||
$this->log(sprintf('%-40s | %s', 'Repo', 'Status'));
|
||||
$this->log(str_repeat('-', 60));
|
||||
|
||||
$failCount = 0;
|
||||
|
||||
foreach ($repos as $repo)
|
||||
{
|
||||
$repo = trim($repo);
|
||||
|
||||
if ($repo === '' || strpos($repo, '/') === false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
[$owner, $repoName] = explode('/', $repo, 2);
|
||||
|
||||
if ($this->dryRun)
|
||||
{
|
||||
$this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = ['ref' => $this->ref];
|
||||
|
||||
if ($inputsDecoded !== null)
|
||||
{
|
||||
$payload['inputs'] = $inputsDecoded;
|
||||
}
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches",
|
||||
json_encode($payload)
|
||||
);
|
||||
|
||||
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(sprintf('%-40s | %s', $repo, $status));
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
|
||||
|
||||
return $failCount > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
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 '--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
|
||||
{
|
||||
$this->log('Usage: bulk_workflow_trigger.php --token <token> --workflow <file> [options]');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
|
||||
$this->log(' --token <token> Gitea API token');
|
||||
$this->log(' --repos <file> File with newline-separated owner/repo list');
|
||||
$this->log(' --org <org> Trigger on all repos in an org');
|
||||
$this->log(' --workflow <filename> Workflow file (e.g., "sync-servers.yml")');
|
||||
$this->log(' --ref <branch> Branch ref (default: "main")');
|
||||
$this->log(' --inputs <json> Workflow inputs as JSON string');
|
||||
$this->log(' --dry-run Show what would be done without triggering');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function buildRepoList(): ?array
|
||||
{
|
||||
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);
|
||||
$lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool {
|
||||
return $line !== '' && $line[0] !== '#';
|
||||
});
|
||||
|
||||
return array_values($lines);
|
||||
}
|
||||
|
||||
// Fetch all repos from org
|
||||
$this->log("Fetching repos from org: {$this->org}");
|
||||
|
||||
$page = 1;
|
||||
$repos = [];
|
||||
|
||||
while (true)
|
||||
{
|
||||
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
||||
{
|
||||
if ($page === 1)
|
||||
{
|
||||
$this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']}).");
|
||||
return null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($data) || count($data) === 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($data as $repo)
|
||||
{
|
||||
$fullName = $repo['full_name'] ?? '';
|
||||
|
||||
if ($fullName !== '')
|
||||
{
|
||||
$repos[] = $fullName;
|
||||
}
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
|
||||
$this->log('Found ' . count($repos) . " repo(s) in org \"{$this->org}\".");
|
||||
|
||||
return $repos;
|
||||
}
|
||||
|
||||
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 BulkWorkflowTrigger();
|
||||
exit($app->run());
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/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_promote.php
|
||||
* BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry
|
||||
*
|
||||
* Usage:
|
||||
* php changelog_promote.php --path /repo --version 04.01.00
|
||||
* php changelog_promote.php --path /repo --version 04.01.00 --date 2026-05-21
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$date = date('Y-m-d');
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--date' && isset($argv[$i + 1])) $date = $argv[$i + 1];
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$changelog = realpath($path) . '/CHANGELOG.md';
|
||||
if (!file_exists($changelog)) {
|
||||
fwrite(STDERR, "No CHANGELOG.md found at {$path}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$content = file_get_contents($changelog);
|
||||
|
||||
// Check if [Unreleased] section exists
|
||||
if (!preg_match('/## \[?Unreleased\]?/i', $content)) {
|
||||
fwrite(STDERR, "No [Unreleased] section found in CHANGELOG.md\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Replace [Unreleased] with versioned entry
|
||||
$content = preg_replace(
|
||||
'/## \[Unreleased\]/i',
|
||||
"## [{$version}] --- {$date}",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
$content = preg_replace(
|
||||
'/## Unreleased/i',
|
||||
"## [{$version}] --- {$date}",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
|
||||
// Insert new [Unreleased] section after the first heading line (# Changelog)
|
||||
$lines = explode("\n", $content);
|
||||
$inserted = false;
|
||||
$result = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$result[] = $line;
|
||||
if (!$inserted && preg_match('/^# /', $line)) {
|
||||
$result[] = '';
|
||||
$result[] = '## [Unreleased]';
|
||||
$result[] = '';
|
||||
$inserted = true;
|
||||
}
|
||||
}
|
||||
|
||||
$content = implode("\n", $result);
|
||||
file_put_contents($changelog, $content);
|
||||
echo "CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}\n";
|
||||
exit(0);
|
||||
@@ -0,0 +1,529 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_dashboard.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Generate unified client dashboard HTML
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ClientDashboard
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private string $org = 'MokoConsulting';
|
||||
private string $outputFile = '';
|
||||
private bool $checkSsl = true;
|
||||
private bool $checkUptime = true;
|
||||
private int $sslWarnDays = 30;
|
||||
private int $httpTimeout = 10;
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->token = getenv('GA_TOKEN') ?: '';
|
||||
}
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->log('ERROR: --token or GA_TOKEN required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('Gathering client data...');
|
||||
$clients = $this->discoverClients();
|
||||
|
||||
if ($clients === null) {
|
||||
$this->log('ERROR: Could not fetch client repos.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('Found ' . count($clients) . ' client(s).');
|
||||
|
||||
foreach ($clients as &$client) {
|
||||
$this->enrichClient($client);
|
||||
}
|
||||
|
||||
unset($client);
|
||||
|
||||
$html = $this->renderDashboard($clients);
|
||||
|
||||
if ($this->outputFile !== '') {
|
||||
file_put_contents($this->outputFile, $html);
|
||||
$this->log("Dashboard: {$this->outputFile}");
|
||||
} else {
|
||||
fwrite(STDOUT, $html);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>>|null */
|
||||
private function discoverClients(): ?array
|
||||
{
|
||||
$clients = [];
|
||||
$orgs = $this->fetchAllOrgs();
|
||||
|
||||
if (!in_array($this->org, $orgs, true)) {
|
||||
array_unshift($orgs, $this->org);
|
||||
}
|
||||
|
||||
foreach ($orgs as $orgName) {
|
||||
$page = 1;
|
||||
|
||||
while (true) {
|
||||
$resp = $this->api(
|
||||
'GET',
|
||||
"/api/v1/orgs/{$orgName}/repos"
|
||||
. "?limit=50&page={$page}"
|
||||
);
|
||||
|
||||
if ($resp['code'] !== 200) {
|
||||
break;
|
||||
}
|
||||
|
||||
$repos = json_decode($resp['body'], true);
|
||||
|
||||
if (!is_array($repos) || empty($repos)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$name = $repo['name'] ?? '';
|
||||
|
||||
if (
|
||||
!str_starts_with($name, 'client-waas-')
|
||||
|| !empty($repo['archived'])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clients[] = [
|
||||
'repo' => $repo['full_name'] ?? '',
|
||||
'name' => str_replace('client-waas-', '', $name),
|
||||
'description' => $repo['description'] ?? '',
|
||||
'updated' => $repo['updated_at'] ?? '',
|
||||
'url' => $repo['html_url'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
}
|
||||
|
||||
usort($clients, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||
|
||||
return $clients;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function fetchAllOrgs(): array
|
||||
{
|
||||
$resp = $this->api('GET', '/api/v1/user/orgs?limit=50');
|
||||
|
||||
if ($resp['code'] !== 200) {
|
||||
return [$this->org];
|
||||
}
|
||||
|
||||
$orgs = json_decode($resp['body'], true);
|
||||
|
||||
if (!is_array($orgs)) {
|
||||
return [$this->org];
|
||||
}
|
||||
|
||||
return array_map(fn($o) => $o['username'] ?? '', $orgs);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $client */
|
||||
private function enrichClient(array &$client): void
|
||||
{
|
||||
$repo = $client['repo'];
|
||||
$this->log(" Checking {$client['name']}...");
|
||||
|
||||
// Fetch variables
|
||||
$resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables");
|
||||
$vars = [];
|
||||
|
||||
if ($resp['code'] === 200) {
|
||||
$varList = json_decode($resp['body'], true);
|
||||
|
||||
if (is_array($varList)) {
|
||||
foreach ($varList as $v) {
|
||||
$vars[$v['name'] ?? ''] = $v['data'] ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$client['vars'] = $vars;
|
||||
$client['dev_url'] = $vars['DEV_SITE_URL'] ?? '';
|
||||
$client['live_url'] = $vars['LIVE_SITE_URL'] ?? '';
|
||||
$client['has_dev'] = isset($vars['DEV_SYNC_HOST']);
|
||||
$client['has_live'] = isset($vars['LIVE_SSH_HOST']);
|
||||
$client['dev_status'] = 'unknown';
|
||||
$client['live_status'] = 'unknown';
|
||||
|
||||
if ($this->checkUptime) {
|
||||
if ($client['dev_url'] !== '') {
|
||||
$client['dev_status'] = $this->checkHttp($client['dev_url']);
|
||||
}
|
||||
|
||||
if ($client['live_url'] !== '') {
|
||||
$client['live_status'] = $this->checkHttp($client['live_url']);
|
||||
}
|
||||
}
|
||||
|
||||
// SSL
|
||||
$client['ssl_expiry'] = null;
|
||||
$client['ssl_days'] = null;
|
||||
$client['ssl_status'] = 'unknown';
|
||||
$domain = $vars['MONITORED_DOMAINS'] ?? '';
|
||||
|
||||
if ($domain === '' && $client['live_url'] !== '') {
|
||||
$parsed = parse_url($client['live_url']);
|
||||
$domain = $parsed['host'] ?? '';
|
||||
}
|
||||
|
||||
if ($this->checkSsl && $domain !== '') {
|
||||
$domain = trim(explode("\n", $domain)[0]);
|
||||
$ssl = $this->checkSslCert($domain);
|
||||
$client['ssl_domain'] = $domain;
|
||||
$client['ssl_expiry'] = $ssl['expiry'];
|
||||
$client['ssl_days'] = $ssl['days'];
|
||||
|
||||
if ($ssl['days'] === null) {
|
||||
$client['ssl_status'] = 'error';
|
||||
} elseif ($ssl['days'] < $this->sslWarnDays) {
|
||||
$client['ssl_status'] = 'warning';
|
||||
} else {
|
||||
$client['ssl_status'] = 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
// Last release
|
||||
$client['last_release'] = '';
|
||||
$client['last_release_date'] = '';
|
||||
$relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1");
|
||||
|
||||
if ($relResp['code'] === 200) {
|
||||
$rels = json_decode($relResp['body'], true);
|
||||
|
||||
if (is_array($rels) && !empty($rels)) {
|
||||
$client['last_release'] = $rels[0]['name'] ?? '';
|
||||
$client['last_release_date'] = substr($rels[0]['created_at'] ?? '', 0, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkHttp(string $url): string
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_NOBODY, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->httpTimeout);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 0) {
|
||||
return 'down';
|
||||
}
|
||||
|
||||
return ($code >= 200 && $code < 400) ? 'up' : "http-{$code}";
|
||||
}
|
||||
|
||||
/** @return array{expiry: ?string, days: ?int} */
|
||||
private function checkSslCert(string $domain): array
|
||||
{
|
||||
$ctx = stream_context_create([
|
||||
'ssl' => [
|
||||
'capture_peer_cert' => true,
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$client = @stream_socket_client(
|
||||
"ssl://{$domain}:443",
|
||||
$errno,
|
||||
$errstr,
|
||||
$this->httpTimeout,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$ctx
|
||||
);
|
||||
|
||||
if (!$client) {
|
||||
return ['expiry' => null, 'days' => null];
|
||||
}
|
||||
|
||||
$params = stream_context_get_params($client);
|
||||
fclose($client);
|
||||
|
||||
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
|
||||
|
||||
if ($cert === null) {
|
||||
return ['expiry' => null, 'days' => null];
|
||||
}
|
||||
|
||||
$info = openssl_x509_parse($cert);
|
||||
$validTo = $info['validTo_time_t'] ?? 0;
|
||||
|
||||
if ($validTo === 0) {
|
||||
return ['expiry' => null, 'days' => null];
|
||||
}
|
||||
|
||||
$expiry = date('Y-m-d', $validTo);
|
||||
$days = (int) round(($validTo - time()) / 86400);
|
||||
|
||||
return ['expiry' => $expiry, 'days' => $days];
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $clients */
|
||||
private function renderDashboard(array $clients): string
|
||||
{
|
||||
$generated = date('Y-m-d H:i:s T');
|
||||
$total = count($clients);
|
||||
$up = 0;
|
||||
$sslWarn = 0;
|
||||
|
||||
foreach ($clients as $c) {
|
||||
if ($c['live_status'] === 'up' || $c['dev_status'] === 'up') {
|
||||
$up++;
|
||||
}
|
||||
|
||||
if ($c['ssl_status'] === 'warning') {
|
||||
$sslWarn++;
|
||||
}
|
||||
}
|
||||
|
||||
$cards = '';
|
||||
|
||||
foreach ($clients as $c) {
|
||||
$cards .= $this->renderCard($c);
|
||||
}
|
||||
|
||||
$warnCls = $sslWarn > 0 ? 'stat-warn' : 'stat-ok';
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Moko Client Dashboard</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:24px}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:4px}
|
||||
.sub{color:#94a3b8;font-size:.875rem;margin-bottom:24px}
|
||||
.stats{display:flex;gap:16px;margin-bottom:24px;flex-wrap:wrap}
|
||||
.st{background:#1e293b;border-radius:8px;padding:16px 20px;min-width:140px}
|
||||
.sv{font-size:1.5rem;font-weight:700}
|
||||
.sl{color:#94a3b8;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
|
||||
.stat-ok .sv{color:#4ade80}
|
||||
.stat-warn .sv{color:#fbbf24}
|
||||
.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
|
||||
.c{background:#1e293b;border-radius:8px;padding:20px;border:1px solid #334155;transition:border-color .2s}
|
||||
.c:hover{border-color:#475569}
|
||||
.ch{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
|
||||
.cn{font-size:1.1rem;font-weight:600;text-transform:capitalize}
|
||||
.cn a{color:#e2e8f0;text-decoration:none}
|
||||
.cn a:hover{color:#60a5fa}
|
||||
.b{font-size:.7rem;padding:2px 8px;border-radius:999px;font-weight:600;text-transform:uppercase}
|
||||
.b-up{background:#064e3b;color:#4ade80}
|
||||
.b-dn{background:#7f1d1d;color:#fca5a5}
|
||||
.b-un{background:#374151;color:#9ca3af}
|
||||
.rs{display:flex;flex-direction:column;gap:8px}
|
||||
.r{display:flex;justify-content:space-between;font-size:.85rem}
|
||||
.rl{color:#94a3b8}
|
||||
.rv{color:#e2e8f0;text-align:right;max-width:60%}
|
||||
.rv a{color:#60a5fa;text-decoration:none}
|
||||
.rv a:hover{text-decoration:underline}
|
||||
.ok{color:#4ade80}.wn{color:#fbbf24}.er{color:#f87171}
|
||||
.st2{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:#64748b;
|
||||
margin-top:8px;margin-bottom:4px;padding-top:8px;border-top:1px solid #334155}
|
||||
footer{margin-top:32px;text-align:center;color:#64748b;font-size:.75rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Moko Client Dashboard</h1>
|
||||
<p class="sub">Generated {$generated}</p>
|
||||
<div class="stats">
|
||||
<div class="st"><div class="sv">{$total}</div><div class="sl">Clients</div></div>
|
||||
<div class="st stat-ok"><div class="sv">{$up}</div><div class="sl">Sites Up</div></div>
|
||||
<div class="st {$warnCls}"><div class="sv">{$sslWarn}</div><div class="sl">SSL Warnings</div></div>
|
||||
</div>
|
||||
<div class="g">{$cards}</div>
|
||||
<footer>Moko Consulting — client_dashboard.php</footer>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $c */
|
||||
private function renderCard(array $c): string
|
||||
{
|
||||
$name = htmlspecialchars($c['name']);
|
||||
$repoUrl = htmlspecialchars($c['url']);
|
||||
|
||||
$ls = $c['live_status'];
|
||||
|
||||
if ($ls === 'up') {
|
||||
$badge = '<span class="b b-up">UP</span>';
|
||||
} elseif ($ls === 'down') {
|
||||
$badge = '<span class="b b-dn">DOWN</span>';
|
||||
} else {
|
||||
$badge = '<span class="b b-un">' . htmlspecialchars($ls) . '</span>';
|
||||
}
|
||||
|
||||
$rows = '';
|
||||
|
||||
if ($c['live_url'] !== '') {
|
||||
$u = htmlspecialchars($c['live_url']);
|
||||
$rows .= "<div class=\"r\"><span class=\"rl\">Live</span>"
|
||||
. "<span class=\"rv\"><a href=\"{$u}\" target=\"_blank\">{$u}</a></span></div>";
|
||||
}
|
||||
|
||||
if ($c['dev_url'] !== '') {
|
||||
$u = htmlspecialchars($c['dev_url']);
|
||||
$ds = $c['dev_status'] === 'up' ? ' (up)' : '';
|
||||
$rows .= "<div class=\"r\"><span class=\"rl\">Dev</span>"
|
||||
. "<span class=\"rv\"><a href=\"{$u}\" target=\"_blank\">{$u}</a>{$ds}</span></div>";
|
||||
}
|
||||
|
||||
if ($c['ssl_days'] !== null) {
|
||||
$cls = match ($c['ssl_status']) {
|
||||
'ok' => 'ok', 'warning' => 'wn', default => 'er'
|
||||
};
|
||||
$stxt = htmlspecialchars("{$c['ssl_expiry']} ({$c['ssl_days']}d)");
|
||||
$rows .= "<div class=\"r\"><span class=\"rl\">SSL</span>"
|
||||
. "<span class=\"rv {$cls}\">{$stxt}</span></div>";
|
||||
}
|
||||
|
||||
if ($c['last_release'] !== '') {
|
||||
$rel = htmlspecialchars($c['last_release']);
|
||||
$rd = htmlspecialchars($c['last_release_date']);
|
||||
$rows .= "<div class=\"r\"><span class=\"rl\">Release</span>"
|
||||
. "<span class=\"rv\">{$rel} ({$rd})</span></div>";
|
||||
}
|
||||
|
||||
$dc = $c['has_dev'] ? '<span class="ok">configured</span>' : '<span class="er">missing</span>';
|
||||
$lc = $c['has_live'] ? '<span class="ok">configured</span>' : '<span class="er">missing</span>';
|
||||
$upd = substr($c['updated'], 0, 10);
|
||||
|
||||
return <<<CARD
|
||||
<div class="c">
|
||||
<div class="ch"><span class="cn"><a href="{$repoUrl}" target="_blank">{$name}</a></span>{$badge}</div>
|
||||
<div class="rs">{$rows}
|
||||
<div class="st2">Infrastructure</div>
|
||||
<div class="r"><span class="rl">Dev Server</span><span class="rv">{$dc}</span></div>
|
||||
<div class="r"><span class="rl">Live Server</span><span class="rv">{$lc}</span></div>
|
||||
<div class="r"><span class="rl">Last Push</span><span class="rv">{$upd}</span></div>
|
||||
</div></div>
|
||||
CARD;
|
||||
}
|
||||
|
||||
/** @return array{code: int, body: string} */
|
||||
private function api(string $method, string $endpoint): 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, [
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$this->token}",
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
curl_close($ch);
|
||||
return ['code' => 0, 'body' => ''];
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
return ['code' => $code, 'body' => $body];
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--output':
|
||||
case '-o':
|
||||
$this->outputFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--no-ssl':
|
||||
$this->checkSsl = false;
|
||||
break;
|
||||
case '--no-uptime':
|
||||
$this->checkUptime = false;
|
||||
break;
|
||||
case '--ssl-warn-days':
|
||||
$this->sslWarnDays = (int) ($args[++$i] ?? 30);
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown arg: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: client_dashboard.php --token TOKEN [options]');
|
||||
$this->log('');
|
||||
$this->log('Generate unified client status dashboard (HTML).');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --token <token> Gitea token (or GA_TOKEN)');
|
||||
$this->log(' --gitea-url <url> Gitea URL');
|
||||
$this->log(' --org <org> Primary org (default: MokoConsulting)');
|
||||
$this->log(' -o, --output <file> Output HTML file (default: stdout)');
|
||||
$this->log(' --no-ssl Skip SSL checks');
|
||||
$this->log(' --no-uptime Skip HTTP uptime checks');
|
||||
$this->log(' --ssl-warn-days <n> SSL warning days (default: 30)');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ClientDashboard();
|
||||
exit($app->run());
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_health_check.php
|
||||
* BRIEF: Verify a client site's update server, installed version, and release availability
|
||||
*
|
||||
* Usage:
|
||||
* php client_health_check.php --update-url URL
|
||||
* php client_health_check.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (reads update server URL from manifest)
|
||||
* --update-url Update server XML URL (overrides manifest)
|
||||
* --site-url Live site URL for version checking via Joomla API (optional)
|
||||
* --api-token Joomla API token for site-url (optional)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$updateUrl = null;
|
||||
$siteUrl = null;
|
||||
$apiToken = null;
|
||||
$ghOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--update-url' && isset($argv[$i + 1])) $updateUrl = $argv[$i + 1];
|
||||
if ($arg === '--site-url' && isset($argv[$i + 1])) $siteUrl = $argv[$i + 1];
|
||||
if ($arg === '--api-token' && isset($argv[$i + 1])) $apiToken = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$checks = [];
|
||||
|
||||
// ── Resolve update server URL from manifest ─────────────────────────────
|
||||
if ($updateUrl === null) {
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
|
||||
$updateUrl = trim($m[1]);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updateUrl === null) {
|
||||
fwrite(STDERR, "No update server URL found. Use --update-url or provide a manifest with <updateservers>.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Update server: {$updateUrl}\n\n";
|
||||
|
||||
// ── Check 1: Update server accessible ───────────────────────────────────
|
||||
echo "--- Update Server ---\n";
|
||||
$ch = curl_init($updateUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200 && !empty($response)) {
|
||||
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
|
||||
$checks['update_server'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$httpCode}\n";
|
||||
$checks['update_server'] = 'fail';
|
||||
}
|
||||
|
||||
// ── Check 2: Parse updates.xml for stable version ───────────────────────
|
||||
$stableVersion = null;
|
||||
$downloadUrl = null;
|
||||
|
||||
if (!empty($response)) {
|
||||
$sections = preg_split('/<update>/', $response);
|
||||
foreach ($sections as $section) {
|
||||
if (strpos($section, '<tag>stable</tag>') !== false) {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
|
||||
$downloadUrl = trim($m[1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n--- Stable Release ---\n";
|
||||
if ($stableVersion !== null) {
|
||||
echo " Version: {$stableVersion}\n";
|
||||
$checks['stable_version'] = $stableVersion;
|
||||
} else {
|
||||
echo " FAIL: Could not parse stable version\n";
|
||||
$checks['stable_version'] = 'fail';
|
||||
}
|
||||
|
||||
// ── Check 3: Download URL accessible ────────────────────────────────────
|
||||
if ($downloadUrl !== null) {
|
||||
echo "\n--- Download URL ---\n";
|
||||
$ch = curl_init($downloadUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
|
||||
curl_close($ch);
|
||||
|
||||
if ($dlCode === 200) {
|
||||
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
|
||||
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
|
||||
$checks['download'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$dlCode}\n";
|
||||
$checks['download'] = 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 4: Site version (optional) ────────────────────────────────────
|
||||
if ($siteUrl !== null && $apiToken !== null) {
|
||||
echo "\n--- Site Version ---\n";
|
||||
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"X-Joomla-Token: {$apiToken}",
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$siteResponse = curl_exec($ch);
|
||||
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($siteCode === 200) {
|
||||
echo " API accessible (HTTP {$siteCode})\n";
|
||||
$checks['site_api'] = 'pass';
|
||||
} else {
|
||||
echo " WARN: Site API returned HTTP {$siteCode}\n";
|
||||
$checks['site_api'] = 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
echo "\n=== Health Check Summary ===\n";
|
||||
$failed = 0;
|
||||
foreach ($checks as $name => $result) {
|
||||
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
|
||||
if ($result === 'fail') $failed++;
|
||||
echo " {$icon}: {$name} = {$result}\n";
|
||||
}
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_inventory.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Discover and list all client-waas repos with their server configuration status
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ClientInventory
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private bool $jsonOutput = false;
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->token === '')
|
||||
{
|
||||
$this->log('ERROR: --token is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Scanning Gitea instance: {$this->giteaUrl}");
|
||||
|
||||
// Step 1: List all orgs
|
||||
$orgs = $this->fetchOrgs();
|
||||
|
||||
if ($orgs === null)
|
||||
{
|
||||
$this->log('ERROR: Failed to fetch organizations.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('Found ' . count($orgs) . ' organization(s).');
|
||||
|
||||
// Step 2 & 3: For each org, find client-waas repos
|
||||
$inventory = [];
|
||||
|
||||
foreach ($orgs as $org)
|
||||
{
|
||||
$orgName = $org['username'] ?? $org['name'] ?? '';
|
||||
|
||||
if ($orgName === '')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$repos = $this->fetchOrgRepos($orgName);
|
||||
|
||||
if ($repos === null)
|
||||
{
|
||||
$this->log("WARNING: Could not fetch repos for org: {$orgName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($repos as $repo)
|
||||
{
|
||||
$repoName = $repo['name'] ?? '';
|
||||
|
||||
if (strpos($repoName, 'client-waas') === false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']);
|
||||
$hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']);
|
||||
|
||||
$lastPush = $repo['updated_at'] ?? 'unknown';
|
||||
|
||||
if ($lastPush !== 'unknown')
|
||||
{
|
||||
$lastPush = substr($lastPush, 0, 19);
|
||||
}
|
||||
|
||||
$status = 'OK';
|
||||
|
||||
if (!$hasDevConfig && !$hasLiveConfig)
|
||||
{
|
||||
$status = 'UNCONFIGURED';
|
||||
}
|
||||
elseif (!$hasDevConfig)
|
||||
{
|
||||
$status = 'NO DEV';
|
||||
}
|
||||
elseif (!$hasLiveConfig)
|
||||
{
|
||||
$status = 'NO LIVE';
|
||||
}
|
||||
|
||||
$inventory[] = [
|
||||
'org' => $orgName,
|
||||
'repo' => $repoName,
|
||||
'has_dev_config' => $hasDevConfig,
|
||||
'has_live_config' => $hasLiveConfig,
|
||||
'last_push' => $lastPush,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Output results
|
||||
if ($this->jsonOutput)
|
||||
{
|
||||
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (count($inventory) === 0)
|
||||
{
|
||||
$this->log('No client-waas repos found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Print table
|
||||
$this->log('');
|
||||
$this->log(sprintf(
|
||||
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
||||
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status'
|
||||
));
|
||||
$this->log(str_repeat('-', 120));
|
||||
|
||||
foreach ($inventory as $entry)
|
||||
{
|
||||
$this->log(sprintf(
|
||||
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
||||
$entry['org'],
|
||||
$entry['repo'],
|
||||
$entry['has_dev_config'] ? 'Yes' : 'No',
|
||||
$entry['has_live_config'] ? 'Yes' : 'No',
|
||||
$entry['last_push'],
|
||||
$entry['status']
|
||||
));
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log('Total: ' . count($inventory) . ' client-waas repo(s).');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
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 '--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
|
||||
{
|
||||
$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
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (is_array($data))
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
$this->log('Admin orgs endpoint unavailable, falling back to user orgs...');
|
||||
|
||||
$response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50');
|
||||
|
||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
||||
{
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (is_array($data))
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function fetchOrgRepos(string $org): ?array
|
||||
{
|
||||
$page = 1;
|
||||
$allRepos = [];
|
||||
|
||||
while (true)
|
||||
{
|
||||
$response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}");
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
||||
{
|
||||
return $page === 1 ? null : $allRepos;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($data) || count($data) === 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
$allRepos = array_merge($allRepos, $data);
|
||||
$page++;
|
||||
}
|
||||
|
||||
return $allRepos;
|
||||
}
|
||||
|
||||
private function checkVariables(string $org, string $repo, array $requiredVars): bool
|
||||
{
|
||||
$response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables");
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($data))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$existingVars = [];
|
||||
|
||||
foreach ($data as $variable)
|
||||
{
|
||||
if (isset($variable['name']))
|
||||
{
|
||||
$existingVars[] = $variable['name'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredVars as $var)
|
||||
{
|
||||
if (!in_array($var, $existingVars, true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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 ClientInventory();
|
||||
exit($app->run());
|
||||
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_provision.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Provision a new client environment end-to-end
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ClientProvision
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $giteaToken = '';
|
||||
private string $grafanaUrl = '';
|
||||
private string $grafanaToken = '';
|
||||
private string $configFile = '';
|
||||
private string $step = '';
|
||||
private bool $dryRun = false;
|
||||
/** @var array<string, mixed> */
|
||||
private array $config = [];
|
||||
private string $org = '';
|
||||
private string $repoName = '';
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->configFile === '') {
|
||||
$this->log('ERROR: --config is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file_exists($this->configFile)) {
|
||||
$this->log("ERROR: Not found: {$this->configFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$json = file_get_contents($this->configFile);
|
||||
$this->config = json_decode($json, true);
|
||||
|
||||
if (!is_array($this->config)) {
|
||||
$this->log('ERROR: Invalid JSON in config file.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->giteaToken = $this->config['gitea_token']
|
||||
?? getenv('GA_TOKEN') ?: '';
|
||||
$this->grafanaUrl = $this->config['grafana_url']
|
||||
?? getenv('GRAFANA_URL') ?: '';
|
||||
$this->grafanaToken = $this->config['grafana_token']
|
||||
?? getenv('GRAFANA_TOKEN') ?: '';
|
||||
$this->giteaUrl = $this->config['gitea_url']
|
||||
?? $this->giteaUrl;
|
||||
|
||||
if ($this->giteaToken === '') {
|
||||
$this->log('ERROR: gitea_token or GA_TOKEN required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->org = $this->config['org'] ?? '';
|
||||
$clientName = $this->config['name'] ?? '';
|
||||
|
||||
if ($this->org === '' || $clientName === '') {
|
||||
$this->log('ERROR: "org" and "name" required in config.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->repoName = 'client-waas-' . $clientName;
|
||||
|
||||
$this->log("=== Client Provisioning: {$clientName} ===");
|
||||
$this->log(" Org: {$this->org}");
|
||||
$this->log(" Repo: {$this->repoName}");
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(' Mode: DRY RUN');
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
|
||||
$steps = [
|
||||
'repo' => 'createRepo',
|
||||
'variables' => 'setVariables',
|
||||
'secrets' => 'setSecrets',
|
||||
'monitoring' => 'setupMonitoring',
|
||||
'summary' => 'printSummary',
|
||||
];
|
||||
|
||||
$exitCode = 0;
|
||||
|
||||
foreach ($steps as $name => $method) {
|
||||
if ($this->step !== '' && $this->step !== $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->$method();
|
||||
|
||||
if ($result !== 0) {
|
||||
$exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
private function createRepo(): int
|
||||
{
|
||||
$this->log('[1/5] Creating repository...');
|
||||
|
||||
$check = $this->giteaApi(
|
||||
'GET',
|
||||
"/api/v1/repos/{$this->org}/{$this->repoName}"
|
||||
);
|
||||
|
||||
if ($check['code'] === 200) {
|
||||
$this->log(" SKIP: repo already exists");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(
|
||||
" WOULD CREATE: {$this->org}/{$this->repoName}"
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'owner' => $this->org,
|
||||
'name' => $this->repoName,
|
||||
'description' => ($this->config['name'] ?? '') . ' WaaS site',
|
||||
'private' => true,
|
||||
'git_content' => true,
|
||||
'topics' => true,
|
||||
'labels' => true,
|
||||
]);
|
||||
|
||||
$resp = $this->giteaApi(
|
||||
'POST',
|
||||
'/api/v1/repos/MokoConsulting/'
|
||||
. 'Template-Client-WaaS/generate',
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($resp['code'] < 200 || $resp['code'] >= 300) {
|
||||
$this->log(" ERROR: HTTP {$resp['code']}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log(' OK: Repo created');
|
||||
|
||||
$this->giteaApi(
|
||||
'POST',
|
||||
"/api/v1/repos/{$this->org}/{$this->repoName}/branches",
|
||||
json_encode([
|
||||
'new_branch_name' => 'dev',
|
||||
'old_branch_name' => 'main',
|
||||
])
|
||||
);
|
||||
|
||||
$this->log(' OK: dev branch created');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function setVariables(): int
|
||||
{
|
||||
$this->log('[2/5] Setting repo variables...');
|
||||
|
||||
$vars = $this->config['variables'] ?? [];
|
||||
|
||||
if (empty($vars)) {
|
||||
$this->log(' SKIP: No variables in config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$errors = 0;
|
||||
$api = "/api/v1/repos/{$this->org}/{$this->repoName}"
|
||||
. "/actions/variables";
|
||||
|
||||
foreach ($vars as $name => $value) {
|
||||
if ($this->dryRun) {
|
||||
$display = strlen($value) > 40
|
||||
? substr($value, 0, 37) . '...' : $value;
|
||||
$this->log(" WOULD SET: {$name} = {$display}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $this->setOrCreateVariable($api, $name, $value);
|
||||
|
||||
if ($ok) {
|
||||
$this->log(" OK: {$name}");
|
||||
} else {
|
||||
$this->log(" ERROR: {$name}");
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function setSecrets(): int
|
||||
{
|
||||
$this->log('[3/5] Setting repo secrets...');
|
||||
|
||||
$secrets = $this->config['secrets'] ?? [];
|
||||
|
||||
if (empty($secrets)) {
|
||||
$this->log(' SKIP: No secrets in config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$errors = 0;
|
||||
$api = "/api/v1/repos/{$this->org}/{$this->repoName}"
|
||||
. "/actions/secrets";
|
||||
|
||||
foreach ($secrets as $name => $value) {
|
||||
if (str_starts_with($value, '@')) {
|
||||
$keyPath = substr($value, 1);
|
||||
|
||||
if (!file_exists($keyPath)) {
|
||||
$this->log(" ERROR: {$name} file not found: {$keyPath}");
|
||||
$errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = file_get_contents($keyPath);
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")");
|
||||
continue;
|
||||
}
|
||||
|
||||
$resp = $this->giteaApi(
|
||||
'PUT',
|
||||
"{$api}/{$name}",
|
||||
json_encode(['data' => $value])
|
||||
);
|
||||
|
||||
if ($resp['code'] >= 200 && $resp['code'] < 300) {
|
||||
$this->log(" OK: {$name}");
|
||||
} else {
|
||||
$this->log(" ERROR: {$name} (HTTP {$resp['code']})");
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function setupMonitoring(): int
|
||||
{
|
||||
$this->log('[4/5] Setting up monitoring...');
|
||||
|
||||
$mon = $this->config['monitoring'] ?? [];
|
||||
|
||||
if (empty($mon)) {
|
||||
$this->log(' SKIP: No monitoring config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dashFile = $mon['grafana_dashboard'] ?? '';
|
||||
|
||||
if (
|
||||
$dashFile !== '' && $this->grafanaUrl !== ''
|
||||
&& $this->grafanaToken !== ''
|
||||
) {
|
||||
$this->pushGrafanaDashboard(
|
||||
$dashFile,
|
||||
$mon['grafana_folder'] ?? 'Clients'
|
||||
);
|
||||
}
|
||||
|
||||
$urls = $mon['urls'] ?? [];
|
||||
$domains = $mon['domains'] ?? [];
|
||||
$api = "/api/v1/repos/{$this->org}/{$this->repoName}"
|
||||
. "/actions/variables";
|
||||
|
||||
if (!empty($urls)) {
|
||||
$urlStr = implode("\n", $urls);
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD SET: MONITORED_URLS");
|
||||
} else {
|
||||
$this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr);
|
||||
$this->log(' OK: MONITORED_URLS');
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($domains)) {
|
||||
$domainStr = implode("\n", $domains);
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD SET: MONITORED_DOMAINS");
|
||||
} else {
|
||||
$this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr);
|
||||
$this->log(' OK: MONITORED_DOMAINS');
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function pushGrafanaDashboard(string $file, string $folder): void
|
||||
{
|
||||
if (!file_exists($file)) {
|
||||
$this->log(" WARN: Dashboard not found: {$file}");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD PUSH: dashboard to \"{$folder}\"");
|
||||
return;
|
||||
}
|
||||
|
||||
$dashboard = json_decode(file_get_contents($file), true);
|
||||
|
||||
if (!is_array($dashboard)) {
|
||||
$this->log(' ERROR: Invalid dashboard JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
$folderId = $this->resolveGrafanaFolder($folder);
|
||||
$dashboard['id'] = null;
|
||||
|
||||
$resp = $this->grafanaApi(
|
||||
'POST',
|
||||
'/api/dashboards/db',
|
||||
json_encode([
|
||||
'dashboard' => $dashboard,
|
||||
'folderId' => $folderId,
|
||||
'overwrite' => true,
|
||||
])
|
||||
);
|
||||
|
||||
if ($resp['code'] === 200) {
|
||||
$data = json_decode($resp['body'], true);
|
||||
$this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
|
||||
} else {
|
||||
$this->log(" ERROR: Dashboard push (HTTP {$resp['code']})");
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveGrafanaFolder(string $title): int
|
||||
{
|
||||
$resp = $this->grafanaApi('GET', '/api/folders');
|
||||
|
||||
if ($resp['code'] !== 200) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$folders = json_decode($resp['body'], true);
|
||||
|
||||
if (!is_array($folders)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($folders as $f) {
|
||||
if (strcasecmp($f['title'] ?? '', $title) === 0) {
|
||||
return (int) ($f['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function printSummary(): int
|
||||
{
|
||||
$vars = $this->config['variables'] ?? [];
|
||||
$secrets = $this->config['secrets'] ?? [];
|
||||
$clientName = $this->config['name'] ?? '';
|
||||
|
||||
$this->log('');
|
||||
$this->log('[5/5] Provisioning summary');
|
||||
$this->log(str_repeat('=', 60));
|
||||
$this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}");
|
||||
$this->log(' Variables: ' . count($vars) . ' set');
|
||||
$this->log(' Secrets: ' . count($secrets) . ' set');
|
||||
$this->log('');
|
||||
$this->log('Next steps:');
|
||||
$this->log(' 1. Clone and customize the Joomla template');
|
||||
$this->log(' 2. Push to dev to trigger dev deployment');
|
||||
$this->log(' 3. Merge dev -> main for production release');
|
||||
$this->log(str_repeat('=', 60));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function setOrCreateVariable(
|
||||
string $api,
|
||||
string $name,
|
||||
string $value
|
||||
): bool {
|
||||
$resp = $this->giteaApi(
|
||||
'PUT',
|
||||
"{$api}/{$name}",
|
||||
json_encode(['value' => $value])
|
||||
);
|
||||
|
||||
if ($resp['code'] === 404) {
|
||||
$resp = $this->giteaApi(
|
||||
'POST',
|
||||
$api,
|
||||
json_encode(['name' => $name, 'value' => $value])
|
||||
);
|
||||
}
|
||||
|
||||
return $resp['code'] >= 200 && $resp['code'] < 300;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--config':
|
||||
$this->configFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--step':
|
||||
$this->step = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown arg: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: client_provision.php --config <file.json> [options]');
|
||||
$this->log('');
|
||||
$this->log('Provision a new client environment end-to-end.');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --config <file> Client config JSON');
|
||||
$this->log(' --step <name> Run one step: repo, variables, secrets, monitoring, summary');
|
||||
$this->log(' --dry-run Preview without changes');
|
||||
$this->log(' --help, -h Show this help');
|
||||
$this->log('');
|
||||
$this->log('Environment variables:');
|
||||
$this->log(' GA_TOKEN Gitea API token');
|
||||
$this->log(' GRAFANA_URL Grafana instance URL');
|
||||
$this->log(' GRAFANA_TOKEN Grafana API token');
|
||||
}
|
||||
|
||||
private function giteaApi(
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
return $this->httpRequest(
|
||||
$this->giteaUrl . $endpoint,
|
||||
$method,
|
||||
"token {$this->giteaToken}",
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
private function grafanaApi(
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
return $this->httpRequest(
|
||||
$this->grafanaUrl . $endpoint,
|
||||
$method,
|
||||
"Bearer {$this->grafanaToken}",
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
private function httpRequest(
|
||||
string $url,
|
||||
string $method,
|
||||
string $auth,
|
||||
?string $body = null
|
||||
): array {
|
||||
$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: {$auth}",
|
||||
]);
|
||||
|
||||
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 ClientProvision();
|
||||
exit($app->run());
|
||||
+11
-12
@@ -7,18 +7,17 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/create_project.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Create baseline GitHub Projects for repositories with standard fields and views
|
||||
*
|
||||
* USAGE
|
||||
* php api/cli/create_project.php --repo MokoCRM # Auto-detect type, create project
|
||||
* php api/cli/create_project.php --repo MokoCRM --type dolibarr # Force type
|
||||
* php api/cli/create_project.php --org mokoconsulting-tech --all # All repos without projects
|
||||
* php api/cli/create_project.php --repo MokoCRM --dry-run # Preview without changes
|
||||
* 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);
|
||||
@@ -160,10 +159,10 @@ function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiCli
|
||||
/**
|
||||
* Detect platform type from .mokostandards file in the repo.
|
||||
*/
|
||||
function detectPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
|
||||
function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
|
||||
{
|
||||
// Try platform metadata dir first, then root
|
||||
foreach (['.github/.mokostandards', '.gitea/.mokostandards', '.mokostandards'] as $path) {
|
||||
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']);
|
||||
@@ -385,7 +384,7 @@ function createProject(
|
||||
updateProjectV2(input: {
|
||||
projectId: $projectId,
|
||||
shortDescription: $shortDescription,
|
||||
readme: "Managed by MokoStandards. Run `php api/cli/create_project.php` to regenerate."
|
||||
readme: "Managed by MokoStandards. Run `php cli/create_project.php` to regenerate."
|
||||
}) {
|
||||
projectV2 { id }
|
||||
}
|
||||
@@ -448,7 +447,7 @@ foreach ($repos as $repo) {
|
||||
// Detect project type
|
||||
$type = $typeOverride;
|
||||
if (!$type) {
|
||||
$platform = detectPlatform($org, $repo, $token);
|
||||
$platform = detectRepoPlatform($org, $repo, $token);
|
||||
$type = $PLATFORM_TO_TYPE[$platform] ?? 'generic';
|
||||
echo " Platform: {$platform} → type: {$type}\n";
|
||||
}
|
||||
|
||||
+9
-11
@@ -7,17 +7,16 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/create_repo.php
|
||||
* VERSION: 04.06.10
|
||||
* BRIEF: Scaffold a new governed repository with full MokoStandards baseline
|
||||
*
|
||||
* USAGE
|
||||
* php api/cli/create_repo.php --name MokoNewModule --type dolibarr --description "My new module"
|
||||
* php api/cli/create_repo.php --name MokoNewModule --type joomla --private
|
||||
* php api/cli/create_repo.php --name MokoNewModule --type generic --dry-run
|
||||
* 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);
|
||||
@@ -159,10 +158,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: {$name}
|
||||
INGROUP: MokoStandards
|
||||
INGROUP: moko-platform
|
||||
REPO: {$repoUrl}
|
||||
PATH: /README.md
|
||||
VERSION: 01.00.00
|
||||
BRIEF: {$description}
|
||||
-->
|
||||
|
||||
@@ -229,7 +227,7 @@ if (!$dryRun) {
|
||||
if (file_exists($syncScript)) {
|
||||
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
|
||||
} else {
|
||||
echo " Run manually: php api/automation/bulk_sync.php --repos {$name} --force --yes\n";
|
||||
echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would run initial sync\n";
|
||||
@@ -242,7 +240,7 @@ if (!$dryRun) {
|
||||
if (file_exists($projectScript)) {
|
||||
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
|
||||
} else {
|
||||
echo " Run manually: php api/cli/create_project.php --repo {$name} --type {$type}\n";
|
||||
echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would create Project\n";
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/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/dev_branch_reset.php
|
||||
* BRIEF: Delete and recreate dev branch from main via Gitea API
|
||||
*
|
||||
* Usage:
|
||||
* php dev_branch_reset.php --token TOKEN --api-base URL
|
||||
* php dev_branch_reset.php --token TOKEN --api-base URL --branch dev --from main
|
||||
*
|
||||
* Options:
|
||||
* --token Gitea API token (required)
|
||||
* --api-base Gitea API base URL (required)
|
||||
* --branch Branch to reset (default: dev)
|
||||
* --from Source branch (default: main)
|
||||
* --output-summary Write to $GITHUB_STEP_SUMMARY
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$branch = 'dev';
|
||||
$from = 'main';
|
||||
$outputSummary = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
||||
if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1];
|
||||
if ($arg === '--output-summary') $outputSummary = true;
|
||||
}
|
||||
|
||||
if ($token === null) $token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
|
||||
if ($token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: dev_branch_reset.php --token TOKEN --api-base URL [--branch dev] [--from main]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Delete branch (tolerate 404)
|
||||
$ch = curl_init("{$apiBase}/branches/{$branch}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($delCode === 204 || $delCode === 200) {
|
||||
echo "Deleted branch '{$branch}'\n";
|
||||
} elseif ($delCode === 404) {
|
||||
echo "Branch '{$branch}' did not exist (skipped delete)\n";
|
||||
} else {
|
||||
fwrite(STDERR, "WARNING: Delete branch returned HTTP {$delCode}\n");
|
||||
}
|
||||
|
||||
// Create branch from source
|
||||
$payload = json_encode(['new_branch_name' => $branch, 'old_branch_name' => $from]);
|
||||
$ch = curl_init("{$apiBase}/branches");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$createCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($createCode === 201) {
|
||||
echo "Recreated '{$branch}' from '{$from}'\n";
|
||||
} else {
|
||||
fwrite(STDERR, "Failed to create branch '{$branch}' from '{$from}' (HTTP {$createCode})\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "Dev branch reset: '{$branch}' recreated from '{$from}'\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/grafana_dashboard.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Manage Grafana dashboards via API
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class GrafanaDashboard
|
||||
{
|
||||
private string $grafanaUrl = '';
|
||||
private string $token = '';
|
||||
private string $command = '';
|
||||
private string $uid = '';
|
||||
private string $file = '';
|
||||
private int $folderId = 0;
|
||||
private string $folderTitle = '';
|
||||
private bool $overwrite = true;
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->grafanaUrl === '') {
|
||||
$this->grafanaUrl = getenv('GRAFANA_URL') ?: '';
|
||||
}
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->token = getenv('GRAFANA_TOKEN') ?: '';
|
||||
}
|
||||
|
||||
if ($this->grafanaUrl === '' || $this->token === '') {
|
||||
$this->log(
|
||||
'ERROR: --url and --token are required '
|
||||
. '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).'
|
||||
);
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
return match ($this->command) {
|
||||
'push' => $this->pushDashboard(),
|
||||
'delete' => $this->deleteDashboard(),
|
||||
'list' => $this->listDashboards(),
|
||||
'export' => $this->exportDashboard(),
|
||||
default => $this->noCommand(),
|
||||
};
|
||||
}
|
||||
|
||||
private function pushDashboard(): int
|
||||
{
|
||||
if ($this->file === '') {
|
||||
$this->log('ERROR: --file is required for push.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file_exists($this->file)) {
|
||||
$this->log("ERROR: File not found: {$this->file}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$json = file_get_contents($this->file);
|
||||
$dashboard = json_decode($json, true);
|
||||
|
||||
if (!is_array($dashboard)) {
|
||||
$this->log('ERROR: Invalid JSON in dashboard file.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->folderTitle !== '' && $this->folderId === 0) {
|
||||
$this->folderId = $this->resolveFolderId(
|
||||
$this->folderTitle
|
||||
);
|
||||
|
||||
if ($this->folderId < 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$dashboard['id'] = null;
|
||||
|
||||
$payload = json_encode([
|
||||
'dashboard' => $dashboard,
|
||||
'folderId' => $this->folderId,
|
||||
'overwrite' => $this->overwrite,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
'/api/dashboards/db',
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
$data = json_decode($response['body'], true);
|
||||
$uid = $data['uid'] ?? '?';
|
||||
$url = $data['url'] ?? '';
|
||||
$status = $data['status'] ?? 'success';
|
||||
$this->log("OK: {$status} (uid: {$uid})");
|
||||
|
||||
if ($url !== '') {
|
||||
$this->log("URL: {$this->grafanaUrl}{$url}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
"ERROR: Push failed (HTTP {$response['code']})"
|
||||
);
|
||||
$this->logApiError($response['body']);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function deleteDashboard(): int
|
||||
{
|
||||
if ($this->uid === '') {
|
||||
$this->log('ERROR: --uid is required for delete.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'DELETE',
|
||||
"/api/dashboards/uid/{$this->uid}"
|
||||
);
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
$this->log("OK: Deleted dashboard {$this->uid}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($response['code'] === 404) {
|
||||
$this->log(
|
||||
"WARN: Dashboard {$this->uid} not found."
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
"ERROR: Delete failed (HTTP {$response['code']})"
|
||||
);
|
||||
$this->logApiError($response['body']);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function listDashboards(): int
|
||||
{
|
||||
$query = '/api/search?type=dash-db';
|
||||
|
||||
if ($this->folderId > 0) {
|
||||
$query .= "&folderIds={$this->folderId}";
|
||||
}
|
||||
|
||||
if ($this->folderTitle !== '' && $this->folderId === 0) {
|
||||
$fid = $this->resolveFolderId($this->folderTitle);
|
||||
|
||||
if ($fid > 0) {
|
||||
$query .= "&folderIds={$fid}";
|
||||
}
|
||||
}
|
||||
|
||||
$response = $this->apiRequest('GET', $query);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
$this->log(
|
||||
"ERROR: List failed (HTTP {$response['code']})"
|
||||
);
|
||||
$this->logApiError($response['body']);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$dashboards = json_decode($response['body'], true);
|
||||
|
||||
if (
|
||||
!is_array($dashboards)
|
||||
|| count($dashboards) === 0
|
||||
) {
|
||||
$this->log('No dashboards found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log(sprintf(
|
||||
'%-30s | %-20s | %s',
|
||||
'Title',
|
||||
'UID',
|
||||
'Folder'
|
||||
));
|
||||
$this->log(str_repeat('-', 75));
|
||||
|
||||
foreach ($dashboards as $d) {
|
||||
$this->log(sprintf(
|
||||
'%-30s | %-20s | %s',
|
||||
substr($d['title'] ?? '', 0, 30),
|
||||
$d['uid'] ?? '',
|
||||
$d['folderTitle'] ?? 'General'
|
||||
));
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log(count($dashboards) . ' dashboard(s).');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function exportDashboard(): int
|
||||
{
|
||||
if ($this->uid === '') {
|
||||
$this->log('ERROR: --uid is required for export.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'GET',
|
||||
"/api/dashboards/uid/{$this->uid}"
|
||||
);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
$this->log(
|
||||
"ERROR: Export failed "
|
||||
. "(HTTP {$response['code']})"
|
||||
);
|
||||
$this->logApiError($response['body']);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
$dashboard = $data['dashboard'] ?? null;
|
||||
|
||||
if ($dashboard === null) {
|
||||
$this->log(
|
||||
'ERROR: No dashboard data in response.'
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output = json_encode(
|
||||
$dashboard,
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
|
||||
) . "\n";
|
||||
|
||||
if ($this->file !== '') {
|
||||
file_put_contents($this->file, $output);
|
||||
$this->log(
|
||||
"Exported {$this->uid} to {$this->file}"
|
||||
);
|
||||
} else {
|
||||
fwrite(STDOUT, $output);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function resolveFolderId(string $title): int
|
||||
{
|
||||
$response = $this->apiRequest('GET', '/api/folders');
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
$this->log(
|
||||
"ERROR: Could not fetch folders "
|
||||
. "(HTTP {$response['code']})"
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
|
||||
$folders = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($folders)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
foreach ($folders as $f) {
|
||||
if (
|
||||
strcasecmp(
|
||||
$f['title'] ?? '',
|
||||
$title
|
||||
) === 0
|
||||
) {
|
||||
return (int) ($f['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$this->log(
|
||||
"WARN: Folder \"{$title}\" not found, "
|
||||
. "using General."
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function noCommand(): int
|
||||
{
|
||||
$this->log('ERROR: No command specified.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case 'push':
|
||||
case 'delete':
|
||||
case 'list':
|
||||
case 'export':
|
||||
$this->command = $args[$i];
|
||||
break;
|
||||
case '--url':
|
||||
$this->grafanaUrl = rtrim(
|
||||
$args[++$i] ?? '',
|
||||
'/'
|
||||
);
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--uid':
|
||||
$this->uid = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--file':
|
||||
$this->file = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--folder-id':
|
||||
$this->folderId = (int) (
|
||||
$args[++$i] ?? 0
|
||||
);
|
||||
break;
|
||||
case '--folder':
|
||||
$this->folderTitle = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--no-overwrite':
|
||||
$this->overwrite = false;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log(
|
||||
"WARNING: Unknown arg: {$args[$i]}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$u = 'Usage: grafana_dashboard.php <command> '
|
||||
. '--url <url> --token <token> [options]';
|
||||
$this->log($u);
|
||||
$this->log('');
|
||||
$this->log('Commands:');
|
||||
$this->log(' push Create/update dashboard from JSON');
|
||||
$this->log(' delete Delete a dashboard by UID');
|
||||
$this->log(' list List dashboards (optionally by folder)');
|
||||
$this->log(' export Export dashboard JSON by UID');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --url <url> Grafana URL (or GRAFANA_URL)');
|
||||
$this->log(' --token <token> API token (or GRAFANA_TOKEN)');
|
||||
$this->log(' --uid <uid> Dashboard UID (delete/export)');
|
||||
$this->log(' --file <path> JSON file (push/export)');
|
||||
$this->log(' --folder <name> Folder name (push/list)');
|
||||
$this->log(' --folder-id <id> Folder ID (push/list)');
|
||||
$this->log(' --no-overwrite Fail if dashboard exists');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function apiRequest(
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
$url = $this->grafanaUrl . $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: Bearer {$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 logApiError(string $body): void
|
||||
{
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (is_array($data) && isset($data['message'])) {
|
||||
$this->log(" Grafana: {$data['message']}");
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new GrafanaDashboard();
|
||||
exit($app->run());
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/joomla_build.php
|
||||
* VERSION: 05.00.01
|
||||
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
|
||||
* NOTE: Called by pre-release and auto-release workflows.
|
||||
*
|
||||
* USAGE
|
||||
* php joomla_build.php --path . --version 02.01.24
|
||||
* php joomla_build.php --path . --version 02.01.24 --suffix -dev
|
||||
* php joomla_build.php --path . --version 02.01.24 --output build --github-output
|
||||
*
|
||||
* Supports: plugin, module, component, template, package, library, file
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ────────────────────────────────────────────────────
|
||||
$path = '.';
|
||||
$version = '';
|
||||
$suffix = '';
|
||||
$outputDir = 'build';
|
||||
$ghOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--suffix' && isset($argv[$i + 1])) $suffix = $argv[$i + 1];
|
||||
if ($arg === '--output' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
}
|
||||
|
||||
if ($version === '') {
|
||||
fwrite(STDERR, "::error::--version is required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$path = realpath($path) ?: $path;
|
||||
|
||||
// ── Find source directory ──────────────────────────────────────────────
|
||||
$srcDir = null;
|
||||
foreach (['src', 'htdocs'] as $d) {
|
||||
if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; }
|
||||
}
|
||||
if ($srcDir === null) {
|
||||
fwrite(STDERR, "::error::No src/ or htdocs/ directory in {$path}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Find manifest ──────────────────────────────────────────────────────
|
||||
$manifest = findManifest($srcDir);
|
||||
if ($manifest === null) {
|
||||
fwrite(STDERR, "::error::No Joomla manifest found in {$srcDir}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "Manifest: {$manifest}\n");
|
||||
|
||||
// ── Parse manifest ─────────────────────────────────────────────────────
|
||||
$meta = parseManifest($manifest);
|
||||
|
||||
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
|
||||
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
|
||||
$resolved = resolveLanguageKey($srcDir, $meta['name']);
|
||||
if ($resolved !== null) { $meta['name'] = $resolved; }
|
||||
}
|
||||
|
||||
$prefix = typePrefix($meta);
|
||||
$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip";
|
||||
$zipPath = "{$outputDir}/{$zipName}";
|
||||
|
||||
fwrite(STDERR, "=== Joomla Build: {$meta['type']} — {$meta['element']} {$version}{$suffix} ===\n");
|
||||
fwrite(STDERR, " Type: {$meta['type']}\n");
|
||||
fwrite(STDERR, " Element: {$meta['element']}\n");
|
||||
fwrite(STDERR, " Group: " . ($meta['group'] ?: 'n/a') . "\n");
|
||||
fwrite(STDERR, " Name: {$meta['name']}\n");
|
||||
fwrite(STDERR, " Output: {$zipName}\n");
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────
|
||||
if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); }
|
||||
|
||||
if ($meta['type'] === 'package') {
|
||||
buildPackageZip($srcDir, $zipPath);
|
||||
} else {
|
||||
buildZip($srcDir, $zipPath);
|
||||
}
|
||||
|
||||
$sha256 = hash_file('sha256', $zipPath);
|
||||
$size = filesize($zipPath);
|
||||
|
||||
fwrite(STDERR, "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)\n");
|
||||
|
||||
// ── Output variables ───────────────────────────────────────────────────
|
||||
$vars = [
|
||||
'zip_name' => $zipName,
|
||||
'zip_path' => $zipPath,
|
||||
'sha256' => $sha256,
|
||||
'ext_type' => $meta['type'],
|
||||
'ext_element' => $meta['element'],
|
||||
'ext_name' => $meta['name'],
|
||||
'ext_group' => $meta['group'],
|
||||
'type_prefix' => $prefix,
|
||||
];
|
||||
|
||||
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
|
||||
$fh = fopen($ghFile, 'a');
|
||||
foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); }
|
||||
fclose($fh);
|
||||
fwrite(STDERR, "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; }
|
||||
}
|
||||
|
||||
exit(0);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Functions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function findManifest(string $dir): ?string
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
function parseManifest(string $file): array
|
||||
{
|
||||
$xml = simplexml_load_file($file);
|
||||
$name = (string) ($xml->name ?? '');
|
||||
$type = (string) ($xml->attributes()->type ?? 'component');
|
||||
$element = (string) ($xml->element ?? '');
|
||||
$group = (string) ($xml->attributes()->group ?? '');
|
||||
|
||||
// For packages, prefer <packagename> as the clean element (avoids pkg_pkg_ duplication)
|
||||
if ($type === 'package' && $element === '') {
|
||||
$packageName = (string) ($xml->packagename ?? '');
|
||||
if ($packageName !== '') {
|
||||
$element = $packageName;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback element detection
|
||||
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); }
|
||||
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); }
|
||||
if ($element === '') {
|
||||
$element = strtolower(basename($file, '.xml'));
|
||||
if (in_array($element, ['templatedetails', 'manifest'], true)) {
|
||||
$element = strtolower(basename(dirname($file)));
|
||||
}
|
||||
}
|
||||
|
||||
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
|
||||
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
|
||||
|
||||
if ($name === '') { $name = $element; }
|
||||
|
||||
return compact('name', 'type', 'element', 'group');
|
||||
}
|
||||
|
||||
function typePrefix(array $meta): string
|
||||
{
|
||||
return match ($meta['type']) {
|
||||
'plugin' => "plg_{$meta['group']}_",
|
||||
'module' => 'mod_',
|
||||
'component' => 'com_',
|
||||
'template' => 'tpl_',
|
||||
'package' => 'pkg_',
|
||||
'library' => 'lib_',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLanguageKey(string $srcDir, string $key): ?string
|
||||
{
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) {
|
||||
foreach (file($item->getPathname()) as $line) {
|
||||
if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExcluded(string $name): bool
|
||||
{
|
||||
if ($name === '.ftpignore') return true;
|
||||
if (str_starts_with($name, 'sftp-config')) return true;
|
||||
if (str_starts_with($name, '.env')) return true;
|
||||
if (str_starts_with($name, '.build-trigger')) return true;
|
||||
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
|
||||
}
|
||||
|
||||
function buildZip(string $srcDir, string $outPath): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "::error::Cannot create ZIP: {$outPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($iter as $file) {
|
||||
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
|
||||
if (isExcluded(basename($local))) continue;
|
||||
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
function buildPackageZip(string $srcDir, string $outPath): void
|
||||
{
|
||||
fwrite(STDERR, "Building Joomla package (multi-extension)...\n");
|
||||
$staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid();
|
||||
mkdir($staging, 0755, true);
|
||||
|
||||
// 1. Zip each sub-extension in packages/
|
||||
$packagesDir = "{$srcDir}/packages";
|
||||
if (is_dir($packagesDir)) {
|
||||
foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) {
|
||||
$subManifest = findManifest($extDir);
|
||||
if ($subManifest) {
|
||||
$sub = parseManifest($subManifest);
|
||||
$subPrefix = typePrefix($sub);
|
||||
$subZipName = "{$subPrefix}{$sub['element']}.zip";
|
||||
} else {
|
||||
$subZipName = basename($extDir) . '.zip';
|
||||
}
|
||||
|
||||
fwrite(STDERR, " Sub-extension: {$subZipName}\n");
|
||||
buildZip($extDir, "{$staging}/{$subZipName}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Copy package-level files (manifest, script, language)
|
||||
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
|
||||
foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
|
||||
foreach (['language', 'administrator'] as $d) {
|
||||
if (is_dir("{$srcDir}/{$d}")) {
|
||||
copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create outer zip
|
||||
buildZip($staging, $outPath);
|
||||
|
||||
// Cleanup
|
||||
rmTree($staging);
|
||||
}
|
||||
|
||||
function copyTree(string $src, string $dst): void
|
||||
{
|
||||
if (!is_dir($dst)) mkdir($dst, 0755, true);
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
$target = "{$dst}/" . $iter->getSubPathname();
|
||||
$item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
|
||||
function rmTree(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) return;
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/joomla_compat_check.php
|
||||
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version
|
||||
*
|
||||
* Usage:
|
||||
* php joomla_compat_check.php --path /repo
|
||||
* php joomla_compat_check.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$ghOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Find manifest and extract targetplatform ────────────────────────────
|
||||
$manifest = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
fwrite(STDERR, "No manifest with targetplatform found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$xml = file_get_contents($manifest);
|
||||
$relManifest = str_replace($root . '/', '', $manifest);
|
||||
|
||||
// Extract targetplatform version regex
|
||||
$targetRegex = '';
|
||||
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
|
||||
$targetRegex = $m[1];
|
||||
}
|
||||
|
||||
if (empty($targetRegex)) {
|
||||
echo "No targetplatform version found in {$relManifest}\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Manifest: {$relManifest}\n";
|
||||
echo "Target regex: {$targetRegex}\n";
|
||||
|
||||
// ── Fetch latest Joomla version ─────────────────────────────────────────
|
||||
$joomlaVersions = [];
|
||||
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
|
||||
if ($updateXml === false) {
|
||||
// Fallback: try the LTS feed
|
||||
$updateUrl = 'https://update.joomla.org/core/list.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
}
|
||||
|
||||
if ($updateXml !== false) {
|
||||
// Parse all version entries
|
||||
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
|
||||
$joomlaVersions = $matches[1] ?? [];
|
||||
}
|
||||
|
||||
if (empty($joomlaVersions)) {
|
||||
echo "WARNING: Could not fetch Joomla versions from update server\n";
|
||||
echo "Tested URL: {$updateUrl}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Sort and get latest
|
||||
usort($joomlaVersions, 'version_compare');
|
||||
$latestJoomla = end($joomlaVersions);
|
||||
|
||||
echo "Latest Joomla: {$latestJoomla}\n";
|
||||
|
||||
// ── Test compatibility ──────────────────────────────────────────────────
|
||||
// The targetplatform regex uses Joomla's regex format
|
||||
// Common patterns: "5\.[0-9]+" or "((5.[0-9])|(6.[0-9]))"
|
||||
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
|
||||
|
||||
if ($compatible === false) {
|
||||
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
|
||||
$result = 'error';
|
||||
} elseif ($compatible === 1) {
|
||||
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
|
||||
$result = 'pass';
|
||||
} else {
|
||||
// Check which major versions are supported
|
||||
$supported = [];
|
||||
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
|
||||
if (@preg_match("/{$targetRegex}/", $v)) {
|
||||
$supported[] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n";
|
||||
echo "Supported versions: " . implode(', ', $supported) . "\n";
|
||||
echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n";
|
||||
$result = 'warn';
|
||||
}
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($result === 'error' ? 1 : 0);
|
||||
+110
-17
@@ -7,27 +7,26 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/joomla_release.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Joomla release pipeline — build ZIP+tar.gz, upload to GitHub Release, update updates.xml
|
||||
*
|
||||
* USAGE
|
||||
* php api/cli/joomla_release.php --repo MokoCassiopeia --stability stable
|
||||
* php api/cli/joomla_release.php --repo MokoCassiopeia --stability development
|
||||
* php api/cli/joomla_release.php --repo MokoCassiopeia --stability rc --dry-run
|
||||
* php api/cli/joomla_release.php --path /local/repo --stability stable
|
||||
* php cli/joomla_release.php --repo MokoCassiopeia --stability stable
|
||||
* php cli/joomla_release.php --repo MokoCassiopeia --stability development
|
||||
* php cli/joomla_release.php --repo MokoCassiopeia --stability rc --dry-run
|
||||
* php cli/joomla_release.php --path /local/repo --stability stable
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, PlatformAdapterFactory};
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
|
||||
|
||||
class JoomlaRelease extends CLIApp
|
||||
class JoomlaRelease extends CliFramework
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
private const ORG = 'mokoconsulting-tech';
|
||||
@@ -49,7 +48,7 @@ class JoomlaRelease extends CLIApp
|
||||
];
|
||||
|
||||
private ApiClient $api;
|
||||
private AuditLogger $logger;
|
||||
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
@@ -76,7 +75,6 @@ class JoomlaRelease extends CLIApp
|
||||
$config = Config::load();
|
||||
$this->adapter = PlatformAdapterFactory::create($config);
|
||||
$this->api = $this->adapter->getApiClient();
|
||||
$this->logger = new AuditLogger('joomla_release');
|
||||
|
||||
if ($repo !== '') {
|
||||
$path = $this->cloneRepo($repo);
|
||||
@@ -118,14 +116,21 @@ class JoomlaRelease extends CLIApp
|
||||
return 1;
|
||||
}
|
||||
|
||||
$zipName = "{$meta['element']}-{$displayVersion}.zip";
|
||||
$tarName = "{$meta['element']}-{$displayVersion}.tar.gz";
|
||||
$prefix = $this->typePrefix($meta);
|
||||
$zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip";
|
||||
$tarName = "{$prefix}{$meta['element']}-{$displayVersion}.tar.gz";
|
||||
$zipPath = sys_get_temp_dir() . "/{$zipName}";
|
||||
$tarPath = sys_get_temp_dir() . "/{$tarName}";
|
||||
|
||||
$this->log('INFO', "Type: {$meta['type']} | Element: {$meta['element']} | Group: {$meta['group']}");
|
||||
|
||||
$sha256 = 'dry-run';
|
||||
if (!$dryRun) {
|
||||
$this->buildZip($srcDir, $zipPath);
|
||||
if ($meta['type'] === 'package') {
|
||||
$this->buildPackageZip($srcDir, $zipPath);
|
||||
} else {
|
||||
$this->buildZip($srcDir, $zipPath);
|
||||
}
|
||||
$this->buildTarGz($srcDir, $tarPath);
|
||||
$sha256 = hash_file('sha256', $zipPath);
|
||||
$this->log('SUCCESS', "ZIP: {$zipName} (" . filesize($zipPath) . " bytes)");
|
||||
@@ -228,6 +233,94 @@ class JoomlaRelease extends CLIApp
|
||||
|
||||
// ── Package building ─────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Get the Joomla type prefix for ZIP naming.
|
||||
*
|
||||
* @param array $meta Parsed manifest metadata
|
||||
* @return string Prefix like "plg_system_", "mod_", "com_", etc.
|
||||
*/
|
||||
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 => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Joomla package ZIP (type="package") with nested sub-extension zips.
|
||||
*
|
||||
* @param string $srcDir Source directory containing pkg_*.xml and packages/
|
||||
* @param string $outPath Output ZIP path
|
||||
*/
|
||||
private function buildPackageZip(string $srcDir, string $outPath): void
|
||||
{
|
||||
$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 = null;
|
||||
foreach (glob("{$extDir}/*.xml") as $xml) {
|
||||
if (str_contains(file_get_contents($xml), '<extension')) {
|
||||
$subManifest = $xml;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subManifest) {
|
||||
$sub = $this->parseManifest($subManifest);
|
||||
$prefix = $this->typePrefix($sub);
|
||||
$subZipName = "{$prefix}{$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->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create the outer zip
|
||||
$this->buildZip($staging, $outPath);
|
||||
|
||||
// Cleanup
|
||||
$this->rmdir($staging);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory.
|
||||
*/
|
||||
private function copyDir(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 buildZip(string $srcDir, string $outPath): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
@@ -404,5 +497,5 @@ class JoomlaRelease extends CLIApp
|
||||
}
|
||||
}
|
||||
|
||||
$script = new JoomlaRelease('joomla_release', 'Joomla release pipeline');
|
||||
exit($script->execute());
|
||||
$app = new JoomlaRelease();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/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_read.php
|
||||
* VERSION: 04.09.00
|
||||
* BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption
|
||||
*
|
||||
* Usage:
|
||||
* php manifest_read.php --path /repo --field platform
|
||||
* php manifest_read.php --path /repo --field entry-point
|
||||
* php manifest_read.php --path /repo --all
|
||||
* php manifest_read.php --path /repo --github-output
|
||||
*
|
||||
* Fields: name, org, description, license, license-spdx, platform,
|
||||
* standards-version, standards-source, language, package-type, entry-point,
|
||||
* source-dir, remote-subdir, excludes, dev-host, demo-host
|
||||
*
|
||||
* --all Print all fields as KEY=VALUE lines
|
||||
* --github-output Append all fields to $GITHUB_OUTPUT (for Gitea/GitHub Actions)
|
||||
* --json Output all fields as JSON
|
||||
* --field <name> Print a single field value (no key, just value)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// -- Argument parsing ---------------------------------------------------------
|
||||
$path = '.';
|
||||
$field = null;
|
||||
$mode = 'field'; // field | all | github-output | json
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--field' && isset($argv[$i + 1])) $field = $argv[$i + 1];
|
||||
if ($arg === '--all') $mode = 'all';
|
||||
if ($arg === '--github-output') $mode = 'github-output';
|
||||
if ($arg === '--json') $mode = 'json';
|
||||
}
|
||||
|
||||
// -- Locate manifest ----------------------------------------------------------
|
||||
$root = realpath($path) ?: $path;
|
||||
$manifestFile = null;
|
||||
|
||||
// Priority: manifest.xml (current standard)
|
||||
$candidates = [
|
||||
"{$root}/.mokogitea/manifest.xml",
|
||||
"{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed)
|
||||
"{$root}/.mokogitea/.moko-platform", // legacy v4
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (file_exists($candidate)) {
|
||||
$manifestFile = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifestFile === null) {
|
||||
fwrite(STDERR, "No manifest found in {$root}
|
||||
");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// -- Parse XML ----------------------------------------------------------------
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
|
||||
if ($xml === false) {
|
||||
// Fallback: try YAML format (.mokostandards legacy)
|
||||
$content = file_get_contents($manifestFile);
|
||||
$fields = [];
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
$fields['platform'] = trim($m[1], "
|
||||
|
||||
\"'");
|
||||
}
|
||||
if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) {
|
||||
$fields['standards-version'] = trim($m[1], "
|
||||
|
||||
\"'");
|
||||
}
|
||||
if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) {
|
||||
$fields['name'] = trim($m[1], "
|
||||
|
||||
\"'");
|
||||
}
|
||||
} else {
|
||||
// Register namespace for XPath (optional, simple path works without)
|
||||
$fields = [
|
||||
'name' => (string)($xml->identity->name ?? ''),
|
||||
'org' => (string)($xml->identity->org ?? ''),
|
||||
'description' => (string)($xml->identity->description ?? ''),
|
||||
'license' => (string)($xml->identity->license ?? ''),
|
||||
'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''),
|
||||
'platform' => (string)($xml->governance->platform ?? ''),
|
||||
'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''),
|
||||
'standards-source' => (string)($xml->governance->{"standards-source"} ?? ''),
|
||||
'language' => (string)($xml->build->language ?? ''),
|
||||
'package-type' => (string)($xml->build->{"package-type"} ?? ''),
|
||||
'entry-point' => (string)($xml->build->{"entry-point"} ?? ''),
|
||||
'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
|
||||
'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
|
||||
'excludes' => (string)($xml->deploy->excludes ?? ''),
|
||||
'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''),
|
||||
'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
|
||||
'manifest-file' => $manifestFile,
|
||||
];
|
||||
}
|
||||
|
||||
// Strip empty values for cleaner output
|
||||
$fields = array_filter($fields, fn($v) => $v !== '');
|
||||
|
||||
// -- Output -------------------------------------------------------------------
|
||||
switch ($mode) {
|
||||
case 'field':
|
||||
if ($field === null) {
|
||||
fwrite(STDERR, "Usage: manifest_read.php --path <dir> --field <name>
|
||||
");
|
||||
fwrite(STDERR, " manifest_read.php --path <dir> --all
|
||||
");
|
||||
fwrite(STDERR, " manifest_read.php --path <dir> --json
|
||||
");
|
||||
fwrite(STDERR, " manifest_read.php --path <dir> --github-output
|
||||
");
|
||||
exit(2);
|
||||
}
|
||||
echo ($fields[$field] ?? '') . "
|
||||
";
|
||||
break;
|
||||
|
||||
case 'all':
|
||||
foreach ($fields as $k => $v) {
|
||||
echo "{$k}={$v}
|
||||
";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'json':
|
||||
echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "
|
||||
";
|
||||
break;
|
||||
|
||||
case 'github-output':
|
||||
$outputFile = getenv('GITHUB_OUTPUT');
|
||||
if ($outputFile === false || $outputFile === '') {
|
||||
fwrite(STDERR, "GITHUB_OUTPUT not set — printing to stdout instead
|
||||
");
|
||||
foreach ($fields as $k => $v) {
|
||||
// Convert field-name to FIELD_NAME for env var style
|
||||
$envKey = str_replace('-', '_', $k);
|
||||
echo "{$envKey}={$v}
|
||||
";
|
||||
}
|
||||
} else {
|
||||
$fh = fopen($outputFile, 'a');
|
||||
foreach ($fields as $k => $v) {
|
||||
$envKey = str_replace('-', '_', $k);
|
||||
fwrite($fh, "{$envKey}={$v}
|
||||
");
|
||||
}
|
||||
fclose($fh);
|
||||
fwrite(STDERR, "Wrote " . count($fields) . " fields to GITHUB_OUTPUT
|
||||
");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/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/package_build.php
|
||||
* BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects
|
||||
*
|
||||
* Usage:
|
||||
* php package_build.php --path /repo --version 04.01.00
|
||||
* php package_build.php --path /repo --version 04.01.00 --output-dir /tmp
|
||||
* php package_build.php --path /repo --version 04.01.00 --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --version Version string (required)
|
||||
* --output-dir Directory for built packages (default: /tmp)
|
||||
* --type-prefix Override type prefix (e.g. plg_system_)
|
||||
* --element Override element name
|
||||
* --github-output Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT
|
||||
*
|
||||
* NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$outputDir = '/tmp';
|
||||
$typePrefixOverride = null;
|
||||
$elementOverride = null;
|
||||
$githubOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--output-dir' && isset($argv[$i + 1])) {
|
||||
$outputDir = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--type-prefix' && isset($argv[$i + 1])) {
|
||||
$typePrefixOverride = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--element' && isset($argv[$i + 1])) {
|
||||
$elementOverride = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--github-output') {
|
||||
$githubOutput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0755, true);
|
||||
}
|
||||
|
||||
// -- Determine source directory -----------------------------------------------
|
||||
$sourceDir = null;
|
||||
foreach (['src', 'htdocs'] as $candidate) {
|
||||
if (is_dir("{$root}/{$candidate}")) {
|
||||
$sourceDir = "{$root}/{$candidate}";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($sourceDir === null) {
|
||||
fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// -- Determine element and type prefix from manifest --------------------------
|
||||
$extElement = $elementOverride;
|
||||
$typePrefix = $typePrefixOverride ?? '';
|
||||
$extType = '';
|
||||
$isPackage = false;
|
||||
|
||||
if ($extElement === null || $typePrefixOverride === null) {
|
||||
// Find manifest
|
||||
$manifest = null;
|
||||
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($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) {
|
||||
$extElement = strtolower(basename($root));
|
||||
}
|
||||
|
||||
// Prevent double prefix (e.g. pkg_pkg_mokogallery)
|
||||
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
|
||||
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
|
||||
}
|
||||
|
||||
$zipName = "{$typePrefix}{$extElement}-{$version}.zip";
|
||||
$tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz";
|
||||
$zipPath = "{$outputDir}/{$zipName}";
|
||||
$tarPath = "{$outputDir}/{$tarName}";
|
||||
|
||||
// -- Exclude patterns ---------------------------------------------------------
|
||||
$excludePatterns = [
|
||||
'.ftpignore',
|
||||
'sftp-config*',
|
||||
'*.ppk',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'.env*',
|
||||
];
|
||||
|
||||
// -- Build packages -----------------------------------------------------------
|
||||
if ($isPackage) {
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
|
||||
|
||||
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
|
||||
$packagesDir = "{$stagingDir}/packages";
|
||||
mkdir($packagesDir, 0755, true);
|
||||
|
||||
// ZIP each sub-extension into packages/
|
||||
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
|
||||
$subName = basename($extDir);
|
||||
echo " Packaging sub-extension: {$subName}\n";
|
||||
|
||||
$subZip = new ZipArchive();
|
||||
$subZipPath = "{$packagesDir}/{$subName}.zip";
|
||||
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP for {$subName}\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
|
||||
$subZip->close();
|
||||
echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n";
|
||||
}
|
||||
|
||||
// Copy package-level files (manifest, script.php, etc.)
|
||||
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
|
||||
copy($f, "{$stagingDir}/" . basename($f));
|
||||
}
|
||||
|
||||
// Copy language directory if present
|
||||
if (is_dir("{$sourceDir}/language")) {
|
||||
$langDest = "{$stagingDir}/language";
|
||||
mkdir($langDest, 0755, true);
|
||||
$langIterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator("{$sourceDir}/language", RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($langIterator as $item) {
|
||||
$target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1);
|
||||
if ($item->isDir()) {
|
||||
mkdir($target, 0755, true);
|
||||
} else {
|
||||
copy($item->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create ZIP from staging
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirectoryToZip($zip, $stagingDir, '', []);
|
||||
$zip->close();
|
||||
|
||||
// Create tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($stagingDir)
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
|
||||
// Cleanup staging
|
||||
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
|
||||
passthru($cleanCmd);
|
||||
} else {
|
||||
echo "=== Building standard extension package ===\n";
|
||||
|
||||
// ZIP
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
|
||||
$zip->close();
|
||||
|
||||
// tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$excludeArgs = '';
|
||||
foreach ($excludePatterns as $pattern) {
|
||||
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
|
||||
}
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s%s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($sourceDir),
|
||||
$excludeArgs
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
}
|
||||
|
||||
// -- Calculate SHA-256 --------------------------------------------------------
|
||||
$sha256Zip = hash_file('sha256', $zipPath);
|
||||
$sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : '';
|
||||
|
||||
$zipSize = filesize($zipPath);
|
||||
$tarSize = file_exists($tarPath) ? filesize($tarPath) : 0;
|
||||
|
||||
echo "\n";
|
||||
echo "ZIP: {$zipName} ({$zipSize} bytes)\n";
|
||||
echo " SHA-256: {$sha256Zip}\n";
|
||||
if ($tarSize > 0) {
|
||||
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
|
||||
echo " SHA-256: {$sha256Tar}\n";
|
||||
}
|
||||
|
||||
// -- Export to GITHUB_OUTPUT --------------------------------------------------
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"zip_name={$zipName}",
|
||||
"tar_name={$tarName}",
|
||||
"zip_path={$zipPath}",
|
||||
"tar_path={$tarPath}",
|
||||
"sha256_zip={$sha256Zip}",
|
||||
"sha256_tar={$sha256Tar}",
|
||||
"type_prefix={$typePrefix}",
|
||||
"ext_element={$extElement}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($lines as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
|
||||
// =============================================================================
|
||||
// Helper: recursively add directory contents to a ZipArchive
|
||||
// =============================================================================
|
||||
function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $excludes): void
|
||||
{
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
$filePath = $file->getPathname();
|
||||
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
|
||||
|
||||
// Check excludes
|
||||
$basename = basename($filePath);
|
||||
$skip = false;
|
||||
foreach ($excludes as $pattern) {
|
||||
if (fnmatch($pattern, $basename)) {
|
||||
$skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($skip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize path separators for ZIP
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($relativePath);
|
||||
} else {
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,10 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/platform_detect.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Detect platform from .mokostandards file — outputs platform string
|
||||
*/
|
||||
|
||||
|
||||
+10
-11
@@ -5,18 +5,17 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Automate the MokoStandards version branch release flow
|
||||
*
|
||||
* USAGE
|
||||
* php api/cli/release.php # Release current version
|
||||
* php api/cli/release.php --bump minor # Bump minor, then release
|
||||
* php api/cli/release.php --bump major # Bump major, then release
|
||||
* php api/cli/release.php --dry-run # Preview without changes
|
||||
* 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);
|
||||
@@ -30,10 +29,10 @@ foreach ($argv as $i => $arg) {
|
||||
}
|
||||
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
$syncFile = "{$repoRoot}/api/lib/Enterprise/RepositorySynchronizer.php";
|
||||
$syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php";
|
||||
// Check both workflow directories for the bulk-repo-sync workflow
|
||||
$bulkSyncFile = file_exists("{$repoRoot}/.gitea/workflows/bulk-repo-sync.yml")
|
||||
? "{$repoRoot}/.gitea/workflows/bulk-repo-sync.yml"
|
||||
$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";
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/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_body_update.php
|
||||
* BRIEF: Update Gitea release body with changelog extract and checksums
|
||||
*
|
||||
* Usage:
|
||||
* php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL
|
||||
* php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL --zip-name pkg.zip --zip-sha abc123
|
||||
*
|
||||
* Options:
|
||||
* --path Repo root for CHANGELOG.md (default: .)
|
||||
* --version Version string (required)
|
||||
* --release-tag Gitea release tag (required)
|
||||
* --token Gitea API token (required)
|
||||
* --api-base Gitea API base URL (required)
|
||||
* --zip-name ZIP filename for checksum table
|
||||
* --tar-name tar.gz filename for checksum table
|
||||
* --zip-sha SHA256 of ZIP
|
||||
* --tar-sha SHA256 of tar.gz
|
||||
* --output-summary Write to $GITHUB_STEP_SUMMARY
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$releaseTag = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$zipName = null;
|
||||
$tarName = null;
|
||||
$zipSha = null;
|
||||
$tarSha = null;
|
||||
$outputSummary = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--release-tag' && isset($argv[$i + 1])) $releaseTag = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
if ($arg === '--zip-name' && isset($argv[$i + 1])) $zipName = $argv[$i + 1];
|
||||
if ($arg === '--tar-name' && isset($argv[$i + 1])) $tarName = $argv[$i + 1];
|
||||
if ($arg === '--zip-sha' && isset($argv[$i + 1])) $zipSha = $argv[$i + 1];
|
||||
if ($arg === '--tar-sha' && isset($argv[$i + 1])) $tarSha = $argv[$i + 1];
|
||||
if ($arg === '--output-summary') $outputSummary = true;
|
||||
}
|
||||
|
||||
if ($token === null) $token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
|
||||
if ($version === null || $releaseTag === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_body_update.php --version VER --release-tag TAG --token TOKEN --api-base URL\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Extract changelog section for this version
|
||||
$changelog = '';
|
||||
$clFile = "{$root}/CHANGELOG.md";
|
||||
if (file_exists($clFile)) {
|
||||
$lines = file($clFile, FILE_IGNORE_NEW_LINES);
|
||||
$capturing = false;
|
||||
$clLines = [];
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
|
||||
$capturing = true;
|
||||
continue;
|
||||
}
|
||||
if ($capturing && preg_match('/^## /', $line)) break;
|
||||
if ($capturing) $clLines[] = $line;
|
||||
}
|
||||
$changelog = trim(implode("\n", $clLines));
|
||||
}
|
||||
|
||||
// Build release body
|
||||
$body = "## {$version} (" . date('Y-m-d') . ")\n\n";
|
||||
if (!empty($changelog)) {
|
||||
$body .= "{$changelog}\n\n";
|
||||
}
|
||||
|
||||
if ($zipSha !== null || $tarSha !== null) {
|
||||
$body .= "---\n\n### Checksums\n\n| File | SHA-256 |\n|------|--------|\n";
|
||||
if ($zipName !== null && $zipSha !== null) {
|
||||
$body .= "| `{$zipName}` | `{$zipSha}` |\n";
|
||||
}
|
||||
if ($tarName !== null && $tarSha !== null) {
|
||||
$body .= "| `{$tarName}` | `{$tarSha}` |\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Get release ID by tag
|
||||
$ch = curl_init("{$apiBase}/releases/tags/{$releaseTag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || empty($response)) {
|
||||
fwrite(STDERR, "Failed to get release for tag '{$releaseTag}' (HTTP {$httpCode})\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$release = json_decode($response, true);
|
||||
$releaseId = $release['id'] ?? null;
|
||||
|
||||
if ($releaseId === null) {
|
||||
fwrite(STDERR, "No release ID found for tag '{$releaseTag}'\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// PATCH release body
|
||||
$payload = json_encode(['body' => $body]);
|
||||
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'PATCH',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
fwrite(STDERR, "Failed to update release body (HTTP {$httpCode})\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Release body updated for {$releaseTag} (release #{$releaseId})\n";
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "Release body updated with changelog + checksums\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/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_cascade.php
|
||||
* BRIEF: Delete lesser pre-release channels from Gitea when promoting stability
|
||||
*
|
||||
* Usage:
|
||||
* php release_cascade.php --stability stable --token TOKEN --api-base URL
|
||||
* php release_cascade.php --stability rc --token TOKEN --api-base URL
|
||||
*
|
||||
* Cascade rules:
|
||||
* stable -> deletes development, alpha, beta, release-candidate
|
||||
* rc -> deletes development, alpha, beta
|
||||
* beta -> deletes development, alpha
|
||||
* alpha -> deletes development
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$stability = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
}
|
||||
|
||||
if ($stability === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_cascade.php --stability [stable|rc|beta|alpha] --token TOKEN --api-base URL\n");
|
||||
fwrite(STDERR, " --api-base: e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo\n");
|
||||
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Define cascade hierarchy
|
||||
$cascadeMap = [
|
||||
'stable' => ['development', 'alpha', 'beta', 'release-candidate'],
|
||||
'release-candidate' => ['development', 'alpha', 'beta'],
|
||||
'rc' => ['development', 'alpha', 'beta'],
|
||||
'beta' => ['development', 'alpha'],
|
||||
'alpha' => ['development'],
|
||||
];
|
||||
|
||||
if (!isset($cascadeMap[$stability])) {
|
||||
fwrite(STDERR, "Unknown stability level: {$stability}\n");
|
||||
fwrite(STDERR, "Valid options: stable, rc, beta, alpha\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$tagsToDelete = $cascadeMap[$stability];
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($tagsToDelete as $tag) {
|
||||
// Get release by tag
|
||||
$ch = curl_init("{$apiBase}/releases/tags/{$tag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || empty($response)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$releaseId = $data['id'] ?? null;
|
||||
|
||||
if ($releaseId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete release
|
||||
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// Delete tag
|
||||
$ch = curl_init("{$apiBase}/tags/{$tag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
echo "Deleted: {$tag} (release id: {$releaseId})\n";
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
echo "Cleaned up {$deleted} pre-release channel(s)\n";
|
||||
exit(0);
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/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_manage.php
|
||||
* BRIEF: Create/update Gitea releases, upload assets, update release body
|
||||
*
|
||||
* Usage:
|
||||
* # Create a release
|
||||
* php release_manage.php --action create --tag stable --name "My Plugin 04.01.00" \
|
||||
* --body "Release notes" --target main --token TOKEN --api-base URL
|
||||
*
|
||||
* # Upload assets to a release
|
||||
* php release_manage.php --action upload --tag stable --files "/tmp/pkg.zip,/tmp/pkg.tar.gz" \
|
||||
* --token TOKEN --api-base URL
|
||||
*
|
||||
* # Update release body (e.g. add SHA checksums)
|
||||
* php release_manage.php --action update-body --tag stable --body "New body" \
|
||||
* --token TOKEN --api-base URL
|
||||
*
|
||||
* # Delete a release and its tag
|
||||
* php release_manage.php --action delete --tag stable --token TOKEN --api-base URL
|
||||
*
|
||||
* Options:
|
||||
* --action create | upload | update-body | delete (required)
|
||||
* --tag Release tag name (required)
|
||||
* --name Release name/title (for create)
|
||||
* --body Release body/description (for create, update-body)
|
||||
* --body-file Read body from file instead of --body
|
||||
* --target Target branch/commitish (for create, default: main)
|
||||
* --files Comma-separated file paths to upload (for upload)
|
||||
* --token Gitea API token (or GA_TOKEN/GITEA_TOKEN env var)
|
||||
* --api-base Gitea API base URL (e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo)
|
||||
*
|
||||
* NOTE: This script uses PHP curl for all HTTP operations (no shell calls).
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$action = null;
|
||||
$tag = null;
|
||||
$name = null;
|
||||
$body = null;
|
||||
$bodyFile = null;
|
||||
$target = 'main';
|
||||
$files = [];
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--action' && isset($argv[$i + 1])) $action = $argv[$i + 1];
|
||||
if ($arg === '--tag' && isset($argv[$i + 1])) $tag = $argv[$i + 1];
|
||||
if ($arg === '--name' && isset($argv[$i + 1])) $name = $argv[$i + 1];
|
||||
if ($arg === '--body' && isset($argv[$i + 1])) $body = $argv[$i + 1];
|
||||
if ($arg === '--body-file' && isset($argv[$i + 1])) $bodyFile = $argv[$i + 1];
|
||||
if ($arg === '--target' && isset($argv[$i + 1])) $target = $argv[$i + 1];
|
||||
if ($arg === '--files' && isset($argv[$i + 1])) $files = array_filter(explode(',', $argv[$i + 1]));
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
}
|
||||
|
||||
// Read body from file if specified
|
||||
if ($bodyFile !== null && file_exists($bodyFile)) {
|
||||
$body = file_get_contents($bodyFile);
|
||||
}
|
||||
|
||||
if ($action === null || $tag === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a Gitea API request using curl
|
||||
*/
|
||||
function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$headers = ["Authorization: token {$token}"];
|
||||
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
];
|
||||
|
||||
if ($jsonBody !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
|
||||
} elseif ($filePath !== null) {
|
||||
$headers[] = 'Content-Type: application/octet-stream';
|
||||
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath);
|
||||
}
|
||||
|
||||
$opts[CURLOPT_HTTPHEADER] = $headers;
|
||||
curl_setopt_array($ch, $opts);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response ?: '{}', true) ?: [];
|
||||
return ['code' => $httpCode, 'data' => $data];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get release by tag
|
||||
*/
|
||||
function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
|
||||
{
|
||||
$result = releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
|
||||
if ($result['code'] === 200 && isset($result['data']['id'])) {
|
||||
return $result['data'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Action dispatch ----------------------------------------------------------
|
||||
switch ($action) {
|
||||
case 'create':
|
||||
// Delete existing release if present
|
||||
$existing = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($existing !== null) {
|
||||
$existingId = $existing['id'];
|
||||
releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
|
||||
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'tag_name' => $tag,
|
||||
'name' => $name ?? $tag,
|
||||
'body' => $body ?? '',
|
||||
'target_commitish' => $target,
|
||||
]);
|
||||
|
||||
$result = releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
$releaseId = $result['data']['id'] ?? 'unknown';
|
||||
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
|
||||
} else {
|
||||
fwrite(STDERR, "Failed to create release: HTTP {$result['code']}\n");
|
||||
fwrite(STDERR, json_encode($result['data']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'upload':
|
||||
if (empty($files)) {
|
||||
fwrite(STDERR, "No files specified. Use --files /path/to/file1,/path/to/file2\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$release = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($release === null) {
|
||||
fwrite(STDERR, "No release found for tag: {$tag}\n");
|
||||
exit(1);
|
||||
}
|
||||
$releaseId = $release['id'];
|
||||
|
||||
// Get existing assets to avoid duplicates
|
||||
$assetsResult = releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
|
||||
$existingAssets = $assetsResult['data'] ?? [];
|
||||
|
||||
foreach ($files as $filePath) {
|
||||
$filePath = trim($filePath);
|
||||
if (!file_exists($filePath)) {
|
||||
fwrite(STDERR, "File not found: {$filePath}\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = basename($filePath);
|
||||
|
||||
// Delete existing asset with same name
|
||||
foreach ($existingAssets as $asset) {
|
||||
if (($asset['name'] ?? '') === $fileName) {
|
||||
releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
|
||||
echo "Deleted existing asset: {$fileName}\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload
|
||||
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
|
||||
$result = releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
echo "Uploaded: {$fileName}\n";
|
||||
} else {
|
||||
fwrite(STDERR, "Failed to upload {$fileName}: HTTP {$result['code']}\n");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update-body':
|
||||
$release = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($release === null) {
|
||||
fwrite(STDERR, "No release found for tag: {$tag}\n");
|
||||
exit(1);
|
||||
}
|
||||
$releaseId = $release['id'];
|
||||
|
||||
$payload = json_encode(['body' => $body ?? '']);
|
||||
$result = releaseGiteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
echo "Release body updated for tag: {$tag}\n";
|
||||
} else {
|
||||
fwrite(STDERR, "Failed to update body: HTTP {$result['code']}\n");
|
||||
exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$existing = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($existing !== null) {
|
||||
releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
|
||||
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
echo "Deleted: {$tag} (id: {$existing['id']})\n";
|
||||
} else {
|
||||
echo "No release found for tag: {$tag}\n";
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
fwrite(STDERR, "Unknown action: {$action}\n");
|
||||
fwrite(STDERR, "Valid actions: create, upload, update-body, delete\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -5,11 +5,10 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_notes.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Extract release notes from CHANGELOG.md for a given version
|
||||
*/
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/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_validate.php
|
||||
* BRIEF: Pre-release validation — version consistency, required files, manifest checks
|
||||
*
|
||||
* Usage:
|
||||
* php release_validate.php --path /repo --version 04.01.00
|
||||
* php release_validate.php --path /repo --version 04.01.00 --platform joomla --output-summary
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --version Expected version string (required)
|
||||
* --platform joomla|dolibarr|generic (default: joomla)
|
||||
* --output-summary Write markdown table to $GITHUB_STEP_SUMMARY
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$platform = 'joomla';
|
||||
$outputSummary = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1];
|
||||
if ($arg === '--output-summary') $outputSummary = true;
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$warn = 0;
|
||||
$results = [];
|
||||
|
||||
function addResult(string $check, string $status, string $details): void {
|
||||
global $pass, $fail, $warn, $results;
|
||||
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||
if ($status === 'PASS') $pass++;
|
||||
elseif ($status === 'FAIL') $fail++;
|
||||
elseif ($status === 'WARN') $warn++;
|
||||
}
|
||||
|
||||
// 1. README.md exists and contains VERSION
|
||||
if (!file_exists("{$root}/README.md")) {
|
||||
addResult('README.md', 'FAIL', 'Not found');
|
||||
} else {
|
||||
$readme = file_get_contents("{$root}/README.md");
|
||||
if (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) ||
|
||||
strpos($readme, $version) !== false) {
|
||||
addResult('README.md version', 'PASS', "`{$version}` found");
|
||||
} else {
|
||||
addResult('README.md version', 'FAIL', "`{$version}` not found in README.md");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. CHANGELOG.md exists with matching section
|
||||
if (!file_exists("{$root}/CHANGELOG.md")) {
|
||||
addResult('CHANGELOG.md', 'WARN', 'Not found');
|
||||
} else {
|
||||
$cl = file_get_contents("{$root}/CHANGELOG.md");
|
||||
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl)) {
|
||||
addResult('CHANGELOG.md version', 'PASS', "Section for `{$version}` found");
|
||||
} else {
|
||||
addResult('CHANGELOG.md version', 'WARN', "No section header for `{$version}`");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. LICENSE file exists
|
||||
$licenseFound = false;
|
||||
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
|
||||
if (file_exists("{$root}/{$lf}")) { $licenseFound = true; break; }
|
||||
}
|
||||
addResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
|
||||
|
||||
// 4. Platform-specific checks
|
||||
if ($platform === 'joomla') {
|
||||
// Find XML manifest
|
||||
$manifest = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') !== false) {
|
||||
$manifest = $xmlFile;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($manifest === null) {
|
||||
addResult('XML manifest', 'FAIL', 'No Joomla manifest found');
|
||||
} else {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
|
||||
$mVer = trim($m[1]);
|
||||
if ($mVer === $version) {
|
||||
addResult('Manifest version', 'PASS', "`{$mVer}` matches");
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`");
|
||||
}
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', 'No <version> tag in manifest');
|
||||
}
|
||||
}
|
||||
|
||||
// updates.xml
|
||||
if (!file_exists("{$root}/updates.xml")) {
|
||||
addResult('updates.xml', 'WARN', 'Not found');
|
||||
} else {
|
||||
$ux = file_get_contents("{$root}/updates.xml");
|
||||
if (preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux)) {
|
||||
addResult('updates.xml version', 'PASS', "`{$version}` found");
|
||||
} else {
|
||||
addResult('updates.xml version', 'FAIL', "`{$version}` not in updates.xml");
|
||||
}
|
||||
}
|
||||
} elseif ($platform === 'dolibarr') {
|
||||
$modFile = null;
|
||||
foreach (['src', 'htdocs'] as $sd) {
|
||||
$pattern = "{$root}/{$sd}/mod*.class.php";
|
||||
$matches = glob($pattern);
|
||||
if (!empty($matches)) { $modFile = $matches[0]; break; }
|
||||
}
|
||||
if ($modFile === null) {
|
||||
addResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
|
||||
} else {
|
||||
$mc = file_get_contents($modFile);
|
||||
if (preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc)) {
|
||||
addResult('Dolibarr version', 'PASS', "`{$version}` matches");
|
||||
} else {
|
||||
addResult('Dolibarr version', 'FAIL', "`{$version}` not found in " . basename($modFile));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. composer.json version (if present)
|
||||
if (file_exists("{$root}/composer.json")) {
|
||||
$composer = json_decode(file_get_contents("{$root}/composer.json"), true);
|
||||
if (isset($composer['version'])) {
|
||||
if ($composer['version'] === $version) {
|
||||
addResult('composer.json version', 'PASS', "`{$version}` matches");
|
||||
} else {
|
||||
addResult('composer.json version', 'WARN', "`{$composer['version']}` != `{$version}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
|
||||
foreach ($results as $r) {
|
||||
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
|
||||
}
|
||||
$table .= "\n**Validation: {$pass} passed, {$fail} failed, {$warn} warnings**\n";
|
||||
|
||||
echo $table;
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "### Pre-Release Validation\n\n{$table}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_verify.php
|
||||
* BRIEF: Verify a built release artifact — version, SHA256, disallowed files
|
||||
*
|
||||
* Usage:
|
||||
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00
|
||||
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --updates-xml updates.xml
|
||||
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --output-summary
|
||||
*
|
||||
* Options:
|
||||
* --zip-path Path to ZIP file (required)
|
||||
* --version Expected version string (required)
|
||||
* --platform joomla|dolibarr|generic (default: joomla)
|
||||
* --updates-xml Path to updates.xml for SHA256 comparison
|
||||
* --github-output Export verify_pass, verify_fail to $GITHUB_OUTPUT
|
||||
* --output-summary Write markdown table to $GITHUB_STEP_SUMMARY
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$zipPath = null;
|
||||
$version = null;
|
||||
$platform = 'joomla';
|
||||
$updatesXml = null;
|
||||
$githubOutput = false;
|
||||
$outputSummary = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--zip-path' && isset($argv[$i + 1])) $zipPath = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1];
|
||||
if ($arg === '--updates-xml' && isset($argv[$i + 1])) $updatesXml = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $githubOutput = true;
|
||||
if ($arg === '--output-summary') $outputSummary = true;
|
||||
}
|
||||
|
||||
if ($zipPath === null || $version === null) {
|
||||
fwrite(STDERR, "Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$warn = 0;
|
||||
$results = [];
|
||||
|
||||
function addResult(string $check, string $status, string $details): void {
|
||||
global $pass, $fail, $warn, $results;
|
||||
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||
if ($status === 'PASS') $pass++;
|
||||
elseif ($status === 'FAIL') $fail++;
|
||||
elseif ($status === 'WARN') $warn++;
|
||||
}
|
||||
|
||||
// 1. ZIP exists and is readable
|
||||
if (!file_exists($zipPath) || !is_readable($zipPath)) {
|
||||
addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}");
|
||||
} else {
|
||||
addResult('ZIP exists', 'PASS', basename($zipPath));
|
||||
|
||||
// 2. Extract ZIP
|
||||
$tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid();
|
||||
mkdir($tmpDir, 0755, true);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file');
|
||||
} else {
|
||||
$zip->extractTo($tmpDir);
|
||||
$zip->close();
|
||||
addResult('ZIP extract', 'PASS', 'Extracted successfully');
|
||||
|
||||
// 3. Manifest version check (Joomla)
|
||||
if ($platform === 'joomla') {
|
||||
$manifest = null;
|
||||
foreach (glob("{$tmpDir}/*.xml") as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') !== false) {
|
||||
$manifest = $xmlFile;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($manifest !== null) {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
|
||||
$manifestVer = trim($m[1]);
|
||||
if ($manifestVer === $version) {
|
||||
addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release");
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`");
|
||||
}
|
||||
} else {
|
||||
addResult('Manifest version', 'WARN', 'No <version> tag in manifest');
|
||||
}
|
||||
} else {
|
||||
addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SHA256 vs updates.xml
|
||||
$zipSha = hash_file('sha256', $zipPath);
|
||||
if ($updatesXml !== null && file_exists($updatesXml)) {
|
||||
$uxContent = file_get_contents($updatesXml);
|
||||
if (preg_match('/<sha256>([^<]+)<\/sha256>/', $uxContent, $m)) {
|
||||
$expectedSha = trim($m[1]);
|
||||
if ($zipSha === $expectedSha) {
|
||||
addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
|
||||
} else {
|
||||
addResult('SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`");
|
||||
}
|
||||
} else {
|
||||
addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Disallowed files
|
||||
$disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env'];
|
||||
$found = [];
|
||||
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS));
|
||||
foreach ($rit as $file) {
|
||||
$name = $file->getFilename();
|
||||
if (in_array($name, $disallowed, true)) {
|
||||
$found[] = $name;
|
||||
}
|
||||
}
|
||||
if (count($found) > 0) {
|
||||
addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found)));
|
||||
} else {
|
||||
addResult('Disallowed files', 'PASS', 'None found');
|
||||
}
|
||||
|
||||
// 6. Non-vendor .min files
|
||||
$minCount = 0;
|
||||
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS));
|
||||
foreach ($rit as $file) {
|
||||
$rel = str_replace($tmpDir . '/', '', $file->getPathname());
|
||||
if (strpos($rel, 'vendor/') !== false) continue;
|
||||
if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) {
|
||||
$minCount++;
|
||||
}
|
||||
}
|
||||
if ($minCount > 0) {
|
||||
addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime");
|
||||
} else {
|
||||
addResult('Non-vendor .min files', 'PASS', 'None shipped');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($rit as $file) {
|
||||
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
|
||||
}
|
||||
rmdir($tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
|
||||
foreach ($results as $r) {
|
||||
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
|
||||
}
|
||||
$table .= "\n**Verification: {$pass} passed, {$fail} failed, {$warn} warnings**\n";
|
||||
|
||||
echo $table;
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($githubOutput) {
|
||||
$outputFile = getenv('GITHUB_OUTPUT');
|
||||
if ($outputFile) {
|
||||
file_put_contents($outputFile, "verify_pass={$pass}\nverify_fail={$fail}\nverify_warn={$warn}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/scaffold_client.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ScaffoldClient
|
||||
{
|
||||
private string $name = '';
|
||||
private string $org = '';
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private bool $dryRun = false;
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->name === '' || $this->org === '' || $this->token === '')
|
||||
{
|
||||
$this->log('ERROR: --name, --org, and --token are required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$repoName = 'client-waas-' . $this->name;
|
||||
|
||||
$this->log("Scaffolding client repo: {$this->org}/{$repoName}");
|
||||
$this->log("Gitea URL: {$this->giteaUrl}");
|
||||
|
||||
if ($this->dryRun)
|
||||
{
|
||||
$this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
|
||||
$this->log("[DRY RUN] Repo: {$this->org}/{$repoName}");
|
||||
$this->log("[DRY RUN] Description: \"{$this->name} WaaS site\"");
|
||||
$this->log('[DRY RUN] Would create dev branch from main');
|
||||
$this->printPostSetupInstructions($repoName);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Step 1: Create repo from template
|
||||
$this->log('Step 1: Creating repo from template...');
|
||||
|
||||
$createPayload = json_encode([
|
||||
'owner' => $this->org,
|
||||
'name' => $repoName,
|
||||
'description' => "{$this->name} WaaS site",
|
||||
'private' => true,
|
||||
'git_content' => true,
|
||||
'topics' => true,
|
||||
'labels' => true,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate",
|
||||
$createPayload
|
||||
);
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
||||
{
|
||||
$this->log("ERROR: Failed to create repo (HTTP {$response['code']}).");
|
||||
$this->log("Response: {$response['body']}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Repo created: {$this->org}/{$repoName}");
|
||||
|
||||
// Step 2: Set repo description (already set via generate, but confirm)
|
||||
$this->log('Step 2: Updating repo description...');
|
||||
|
||||
$updatePayload = json_encode([
|
||||
'description' => "{$this->name} WaaS site",
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'PATCH',
|
||||
"/api/v1/repos/{$this->org}/{$repoName}",
|
||||
$updatePayload
|
||||
);
|
||||
|
||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
||||
{
|
||||
$this->log('Description updated.');
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->log("WARNING: Could not update description (HTTP {$response['code']}).");
|
||||
}
|
||||
|
||||
// Step 3: Create dev branch from main
|
||||
$this->log('Step 3: Creating dev branch from main...');
|
||||
|
||||
$branchPayload = json_encode([
|
||||
'new_branch_name' => 'dev',
|
||||
'old_branch_name' => 'main',
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/{$this->org}/{$repoName}/branches",
|
||||
$branchPayload
|
||||
);
|
||||
|
||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
||||
{
|
||||
$this->log('Branch "dev" created from "main".');
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->log("WARNING: Could not create dev branch (HTTP {$response['code']}).");
|
||||
$this->log("Response: {$response['body']}");
|
||||
}
|
||||
|
||||
// Step 4: Print post-setup instructions
|
||||
$this->printPostSetupInstructions($repoName);
|
||||
|
||||
$this->log('Scaffold complete.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++)
|
||||
{
|
||||
switch ($args[$i])
|
||||
{
|
||||
case '--name':
|
||||
$this->name = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown argument: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: scaffold_client.php --name <client-name> --org <gitea-org> --token <token> [options]');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --name <name> Client name (e.g., "clarksvillefurs")');
|
||||
$this->log(' --org <org> Gitea organization (e.g., "ClarksvilleFurs")');
|
||||
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
|
||||
$this->log(' --token <token> Gitea API token');
|
||||
$this->log(' --dry-run Show what would be done without making changes');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function printPostSetupInstructions(string $repoName): void
|
||||
{
|
||||
$this->log('');
|
||||
$this->log('=== POST-SETUP INSTRUCTIONS ===');
|
||||
$this->log('');
|
||||
$this->log("Navigate to: {$this->giteaUrl}/{$this->org}/{$repoName}/settings");
|
||||
$this->log('');
|
||||
$this->log('Set the following REPO VARIABLES (Settings > Actions > Variables):');
|
||||
$this->log(' DEV_SYNC_HOST - Dev server hostname or IP');
|
||||
$this->log(' DEV_SYNC_PORT - Dev server SSH port (default: 22)');
|
||||
$this->log(' DEV_SYNC_USER - Dev server SSH username');
|
||||
$this->log(' DEV_SYNC_PATH - Dev server deploy path');
|
||||
$this->log(' LIVE_SSH_HOST - Live server hostname or IP');
|
||||
$this->log(' LIVE_SSH_PORT - Live server SSH port (default: 22)');
|
||||
$this->log(' LIVE_SSH_USER - Live server SSH username');
|
||||
$this->log(' LIVE_SYNC_PATH - Live server deploy path');
|
||||
$this->log('');
|
||||
$this->log('Set the following REPO SECRETS (Settings > Actions > Secrets):');
|
||||
$this->log(' DEV_SYNC_KEY - Private SSH key for dev server');
|
||||
$this->log(' LIVE_SSH_KEY - Private SSH key for live server');
|
||||
$this->log('');
|
||||
$this->log('================================');
|
||||
}
|
||||
|
||||
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
|
||||
{
|
||||
$url = $this->giteaUrl . $endpoint;
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$this->token}",
|
||||
]);
|
||||
|
||||
if ($body !== null)
|
||||
{
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch))
|
||||
{
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ScaffoldClient();
|
||||
exit($app->run());
|
||||
@@ -7,18 +7,17 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/sync_rulesets.php
|
||||
* VERSION: 04.06.10
|
||||
* BRIEF: Apply branch protection rules to all repos via platform adapter
|
||||
*
|
||||
* USAGE
|
||||
* php api/cli/sync_rulesets.php # Apply to all repos
|
||||
* php api/cli/sync_rulesets.php --repo MokoCRM # Single repo
|
||||
* php api/cli/sync_rulesets.php --dry-run # Preview only
|
||||
* php api/cli/sync_rulesets.php --delete # Remove then re-apply
|
||||
* 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.
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/theme_lint.php
|
||||
* BRIEF: Lint theme files — CSS syntax, image sizes, hardcoded URLs
|
||||
*
|
||||
* Usage:
|
||||
* php theme_lint.php --path /repo
|
||||
* php theme_lint.php --path /repo --max-image-kb 500
|
||||
* php theme_lint.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --max-image-kb Maximum image file size in KB (default: 500)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
* --strict Exit 1 on any warning (default: only on errors)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$maxImageKb = 500;
|
||||
$ghOutput = false;
|
||||
$strict = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--max-image-kb' && isset($argv[$i + 1])) $maxImageKb = (int)$argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
if ($arg === '--strict') $strict = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$warnings = 0;
|
||||
|
||||
// ── Find source directory ───────────────────────────────────────────────
|
||||
$srcDir = null;
|
||||
foreach (['src', 'htdocs'] as $d) {
|
||||
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; }
|
||||
}
|
||||
if ($srcDir === null) {
|
||||
fwrite(STDERR, "No src/ or htdocs/ directory in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Theme Lint: {$srcDir}\n\n";
|
||||
|
||||
// ── Check 1: CSS syntax validation ──────────────────────────────────────
|
||||
echo "--- CSS Syntax ---\n";
|
||||
$cssFiles = findFiles($srcDir, '*.css');
|
||||
$cssMinFiles = findFiles($srcDir, '*.min.css');
|
||||
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
|
||||
|
||||
if (empty($cssToCheck)) {
|
||||
echo " No CSS files to check\n";
|
||||
} else {
|
||||
foreach ($cssToCheck as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
|
||||
// Check for unmatched braces
|
||||
$openBraces = substr_count($content, '{');
|
||||
$closeBraces = substr_count($content, '}');
|
||||
if ($openBraces !== $closeBraces) {
|
||||
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
|
||||
$errors++;
|
||||
}
|
||||
|
||||
// Check for empty rules
|
||||
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
|
||||
$count = count($m[0]);
|
||||
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
|
||||
$warnings++;
|
||||
}
|
||||
|
||||
// Check for !important abuse (more than 10 in one file)
|
||||
$importantCount = substr_count($content, '!important');
|
||||
if ($importantCount > 10) {
|
||||
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors === 0) {
|
||||
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: Image file sizes ───────────────────────────────────────────
|
||||
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
|
||||
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
|
||||
$images = [];
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, findFiles($srcDir, $ext));
|
||||
}
|
||||
// Also check root images/ directory
|
||||
if (is_dir("{$root}/images")) {
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, findFiles("{$root}/images", $ext));
|
||||
}
|
||||
}
|
||||
|
||||
$oversized = 0;
|
||||
$totalSize = 0;
|
||||
foreach ($images as $file) {
|
||||
$size = filesize($file);
|
||||
$totalSize += $size;
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$sizeKb = round($size / 1024);
|
||||
|
||||
if ($sizeKb > $maxImageKb) {
|
||||
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
|
||||
$oversized++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
$totalMb = round($totalSize / 1024 / 1024, 1);
|
||||
echo " " . count($images) . " image(s), {$totalMb}MB total";
|
||||
if ($oversized > 0) {
|
||||
echo ", {$oversized} oversized";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// ── Check 3: Hardcoded URLs in CSS/JS ───────────────────────────────────
|
||||
echo "\n--- Hardcoded URLs ---\n";
|
||||
$codeFiles = array_merge(
|
||||
findFiles($srcDir, '*.css'),
|
||||
findFiles($srcDir, '*.js')
|
||||
);
|
||||
// Exclude minified files
|
||||
$codeFiles = array_filter($codeFiles, function($f) {
|
||||
return !preg_match('/\.min\.(css|js)$/', $f);
|
||||
});
|
||||
|
||||
$urlPatterns = [
|
||||
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
|
||||
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
|
||||
'/https?:\/\/localhost/' => 'localhost reference',
|
||||
];
|
||||
|
||||
$urlIssues = 0;
|
||||
foreach ($codeFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
|
||||
foreach ($urlPatterns as $pattern => $desc) {
|
||||
if (preg_match_all($pattern, $content, $matches)) {
|
||||
$count = count($matches[0]);
|
||||
echo " WARN: {$relPath}: {$count} {$desc}\n";
|
||||
$urlIssues++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($urlIssues === 0) {
|
||||
echo " OK: No hardcoded URLs found\n";
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
echo "\n=== Summary ===\n";
|
||||
echo "Errors: {$errors}\n";
|
||||
echo "Warnings: {$warnings}\n";
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors > 0) {
|
||||
exit(1);
|
||||
}
|
||||
if ($strict && $warnings > 0) {
|
||||
exit(1);
|
||||
}
|
||||
exit(0);
|
||||
|
||||
// ── Helper: recursively find files matching a glob pattern ──────────────
|
||||
function findFiles(string $dir, string $pattern): array
|
||||
{
|
||||
$results = [];
|
||||
if (!is_dir($dir)) return $results;
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (fnmatch($pattern, $file->getFilename())) {
|
||||
$results[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
#!/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/updates_xml_build.php
|
||||
* BRIEF: Generate Joomla updates.xml from extension manifest metadata
|
||||
*
|
||||
* Usage:
|
||||
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable
|
||||
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --sha SHA256
|
||||
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --version Version string (required)
|
||||
* --stability One of: stable, rc, beta, alpha, development (default: stable)
|
||||
* --sha SHA-256 hash of the ZIP package (optional)
|
||||
* --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech)
|
||||
* --org Organization (default: env GITEA_ORG)
|
||||
* --repo Repository name (default: env GITEA_REPO)
|
||||
* --output Output file path (default: updates.xml in --path)
|
||||
* --github-output Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// -- Argument parsing ---------------------------------------------------------
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$stability = 'stable';
|
||||
$sha = null;
|
||||
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
$org = getenv('GITEA_ORG') ?: '';
|
||||
$repo = getenv('GITEA_REPO') ?: '';
|
||||
$outputFile = null;
|
||||
$githubOutput = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
|
||||
if ($arg === '--sha' && isset($argv[$i + 1])) $sha = $argv[$i + 1];
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
|
||||
if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1];
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
|
||||
if ($arg === '--output' && isset($argv[$i + 1])) $outputFile = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $githubOutput = true;
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// -- Locate Joomla manifest ---------------------------------------------------
|
||||
$manifest = null;
|
||||
|
||||
// Priority: pkg_*.xml in src/ > any extension XML in src/ > any in root
|
||||
$candidates = glob("{$root}/src/pkg_*.xml") ?: [];
|
||||
foreach ($candidates as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
$searchDirs = ["{$root}/src", "{$root}"];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// -- Parse extension metadata -------------------------------------------------
|
||||
$xml = file_get_contents($manifest);
|
||||
|
||||
// Extract fields via regex (more portable than SimpleXML for malformed manifests)
|
||||
$extName = '';
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) $extName = $m[1];
|
||||
|
||||
$extType = '';
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
|
||||
|
||||
$extElement = '';
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
|
||||
// For packages, prefer <packagename> to avoid pkg_pkg_ duplication
|
||||
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) $extElement = $m[1];
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) $extElement = $m[1];
|
||||
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) $extElement = $m[1];
|
||||
if (empty($extElement)) {
|
||||
$fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||
if (in_array($fname, ['templatedetails', 'manifest'])) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root)));
|
||||
} else {
|
||||
$extElement = $fname;
|
||||
}
|
||||
}
|
||||
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
|
||||
|
||||
$extClient = '';
|
||||
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) $extClient = $m[1];
|
||||
|
||||
$extFolder = '';
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1];
|
||||
|
||||
$targetPlatform = '';
|
||||
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) $targetPlatform = $m[1];
|
||||
if (empty($targetPlatform)) {
|
||||
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
|
||||
}
|
||||
|
||||
$phpMinimum = '';
|
||||
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) $phpMinimum = $m[1];
|
||||
|
||||
// Resolve language key names (e.g. PLG_SYSTEM_MOKOJOOMTOS)
|
||||
if (preg_match('/^[A-Z_]+$/', $extName)) {
|
||||
$iniFiles = [];
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if (preg_match('/\.sys\.ini$/i', $file->getFilename())) {
|
||||
$iniFiles[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
foreach ($iniFiles as $ini) {
|
||||
$content = file_get_contents($ini);
|
||||
if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) {
|
||||
$extName = $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallbacks
|
||||
if (empty($extName)) $extName = $repo ?: basename($root);
|
||||
if (empty($extType)) $extType = 'component';
|
||||
|
||||
// -- Build type prefix --------------------------------------------------------
|
||||
$typePrefix = '';
|
||||
switch ($extType) {
|
||||
case 'plugin': $typePrefix = "plg_{$extFolder}_"; break;
|
||||
case 'module': $typePrefix = 'mod_'; break;
|
||||
case 'component': $typePrefix = 'com_'; break;
|
||||
case 'template': $typePrefix = 'tpl_'; break;
|
||||
case 'library': $typePrefix = 'lib_'; break;
|
||||
case 'package': $typePrefix = 'pkg_'; break;
|
||||
}
|
||||
|
||||
// -- Export to GITHUB_OUTPUT if requested -------------------------------------
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"ext_element={$extElement}",
|
||||
"ext_name={$extName}",
|
||||
"ext_type={$extType}",
|
||||
"ext_folder={$extFolder}",
|
||||
"type_prefix={$typePrefix}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($lines as $line) echo "{$line}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// -- Stability suffix map -----------------------------------------------------
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
'rc' => '-rc',
|
||||
'beta' => '-beta',
|
||||
'alpha' => '-alpha',
|
||||
'development' => '-dev',
|
||||
];
|
||||
|
||||
// Joomla <tags><tag> values — maps to Joomla's stabilityTagToInteger()
|
||||
$stabilityTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'rc',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'dev',
|
||||
];
|
||||
|
||||
// Gitea release tag names (used in download/info URLs)
|
||||
$releaseTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'release-candidate',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'development',
|
||||
];
|
||||
|
||||
// -- Build update entries -----------------------------------------------------
|
||||
// For the primary entry: apply suffix if not stable
|
||||
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
|
||||
$primaryVersion = $version . $primarySuffix;
|
||||
|
||||
// Build client tag — only needed for templates and modules (site vs admin).
|
||||
// Packages and components don't use client; plugins use folder instead.
|
||||
$clientTag = '';
|
||||
if (!empty($extClient)) {
|
||||
$clientTag = " <client>{$extClient}</client>";
|
||||
} elseif (in_array($extType, ['template', 'module'])) {
|
||||
$clientTag = ' <client>site</client>';
|
||||
}
|
||||
|
||||
// Build folder tag
|
||||
$folderTag = '';
|
||||
if (!empty($extFolder) && $extType === 'plugin') {
|
||||
$folderTag = " <folder>{$extFolder}</folder>";
|
||||
}
|
||||
|
||||
// PHP minimum tag
|
||||
$phpTag = '';
|
||||
if (!empty($phpMinimum)) {
|
||||
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
||||
}
|
||||
|
||||
// SHA tag
|
||||
$shaTag = '';
|
||||
if (!empty($sha)) {
|
||||
$shaTag = " <sha256>{$sha}</sha256>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single <update> entry for a given stability tag
|
||||
*/
|
||||
function buildEntry(
|
||||
string $tagName,
|
||||
string $entryVersion,
|
||||
string $entryDownloadUrl,
|
||||
string $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 ----------------------------------------
|
||||
// Stable cascades to all channels; pre-releases cascade down to lower channels.
|
||||
// Each channel entry represents "latest release available at this stability or higher".
|
||||
// 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)
|
||||
// All cascaded entries point to the CURRENT release (the highest stability being built)
|
||||
$entries = [];
|
||||
$giteaTag = $releaseTagMap[$stability] ?? $stability;
|
||||
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
|
||||
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
|
||||
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
|
||||
|
||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
||||
$channelName = $allChannels[$i];
|
||||
$joomlaTag = $stabilityTagMap[$channelName] ?? $channelName;
|
||||
// Only attach SHA to the primary channel entry
|
||||
$entrySha = ($i === $stabilityIndex) ? $shaTag : '';
|
||||
|
||||
$entries[] = buildEntry(
|
||||
$joomlaTag,
|
||||
$channelVersion,
|
||||
$channelDownloadUrl,
|
||||
$extName,
|
||||
$extElement,
|
||||
$extType,
|
||||
$clientTag,
|
||||
$folderTag,
|
||||
$channelInfoUrl,
|
||||
$targetPlatform,
|
||||
$phpTag,
|
||||
$entrySha
|
||||
);
|
||||
}
|
||||
|
||||
// -- Preserve existing entries for channels not being updated -----------------
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
$preservedEntries = [];
|
||||
|
||||
if (file_exists($dest)) {
|
||||
$existingXml = @simplexml_load_file($dest);
|
||||
if ($existingXml) {
|
||||
// Joomla tags we're writing — don't preserve these
|
||||
$writtenChannels = [];
|
||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
||||
$writtenChannels[] = $stabilityTagMap[$allChannels[$i]] ?? $allChannels[$i];
|
||||
}
|
||||
|
||||
foreach ($existingXml->update as $existingUpdate) {
|
||||
$existingTag = '';
|
||||
if (isset($existingUpdate->tags->tag)) {
|
||||
$existingTag = (string) $existingUpdate->tags->tag;
|
||||
}
|
||||
// Keep entries for channels we're NOT overwriting
|
||||
if (!empty($existingTag) && !in_array($existingTag, $writtenChannels, true)) {
|
||||
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Write updates.xml --------------------------------------------------------
|
||||
$year = date('Y');
|
||||
$output = <<<XML
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<!-- Copyright (C) {$year} Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
VERSION: {$primaryVersion}
|
||||
-->
|
||||
|
||||
<updates>
|
||||
XML;
|
||||
$allEntries = array_merge($preservedEntries, $entries);
|
||||
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
|
||||
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
file_put_contents($dest, $output);
|
||||
|
||||
$channelCount = count($entries);
|
||||
echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n";
|
||||
echo "Output: {$dest}\n";
|
||||
exit(0);
|
||||
@@ -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/updates_xml_sync.php
|
||||
* VERSION: 05.00.01
|
||||
* BRIEF: Sync updates.xml to target branches via Gitea API
|
||||
* NOTE: Called by pre-release and auto-release workflows after updates.xml
|
||||
* is modified on the current branch. Pushes the file to other branches
|
||||
* without requiring a git checkout (avoids merge conflicts).
|
||||
*
|
||||
* Usage:
|
||||
* php updates_xml_sync.php --path /repo --branches main,dev --current dev
|
||||
* php updates_xml_sync.php --path /repo --branches main --current dev --version 02.01.27
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root containing updates.xml (default: .)
|
||||
* --branches Comma-separated target branches to sync to (default: main,dev)
|
||||
* --current Current branch to skip (required)
|
||||
* --version Version string for commit message (optional)
|
||||
* --token Gitea API token (default: env GA_TOKEN)
|
||||
* --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech)
|
||||
* --org Organization (default: env GITEA_ORG)
|
||||
* --repo Repository name (default: env GITEA_REPO)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ────────────────────────────────────────────────────
|
||||
$path = '.';
|
||||
$branches = 'main,dev';
|
||||
$current = '';
|
||||
$version = '';
|
||||
$token = getenv('GA_TOKEN') ?: '';
|
||||
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
$org = getenv('GITEA_ORG') ?: '';
|
||||
$repo = getenv('GITEA_REPO') ?: '';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--branches' && isset($argv[$i + 1])) $branches = $argv[$i + 1];
|
||||
if ($arg === '--current' && isset($argv[$i + 1])) $current = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
|
||||
if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1];
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
|
||||
}
|
||||
|
||||
if ($current === '') {
|
||||
fwrite(STDERR, "Error: --current is required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "Error: --token or GA_TOKEN env is required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($org === '' || $repo === '') {
|
||||
fwrite(STDERR, "Error: --org and --repo (or GITEA_ORG/GITEA_REPO env) are required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$updatesFile = rtrim($path, '/') . '/updates.xml';
|
||||
if (!file_exists($updatesFile)) {
|
||||
fwrite(STDERR, "No updates.xml found at {$updatesFile}\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$content = file_get_contents($updatesFile);
|
||||
$encoded = base64_encode($content);
|
||||
$giteaUrl = rtrim($giteaUrl, '/');
|
||||
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
|
||||
$vLabel = $version !== '' ? " {$version}" : '';
|
||||
|
||||
$targets = array_filter(
|
||||
array_map('trim', explode(',', $branches)),
|
||||
fn($b) => $b !== '' && $b !== $current
|
||||
);
|
||||
|
||||
if (empty($targets)) {
|
||||
fwrite(STDERR, "No target branches to sync to (current: {$current})\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$synced = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($targets as $branch) {
|
||||
fwrite(STDERR, "Syncing updates.xml -> {$branch}...\n");
|
||||
|
||||
$sha = getFileSha($apiBase, $token, $branch);
|
||||
|
||||
if ($sha === null) {
|
||||
fwrite(STDERR, " WARNING: could not get SHA from {$branch}\n");
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = putFile($apiBase, $token, $branch, $encoded, $sha,
|
||||
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]");
|
||||
|
||||
if ($ok) {
|
||||
fwrite(STDERR, " Synced to {$branch}\n");
|
||||
$synced++;
|
||||
} else {
|
||||
fwrite(STDERR, " WARNING: push to {$branch} failed\n");
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
fwrite(STDERR, "Done: {$synced} synced, {$failed} failed\n");
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getFileSha(string $apiBase, string $token, string $branch): ?string
|
||||
{
|
||||
$resp = apiCall('GET', "{$apiBase}/contents/updates.xml?ref={$branch}", $token);
|
||||
return $resp['sha'] ?? null;
|
||||
}
|
||||
|
||||
function putFile(string $apiBase, string $token, string $branch,
|
||||
string $encoded, string $sha, string $msg): bool
|
||||
{
|
||||
$resp = apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [
|
||||
'content' => $encoded,
|
||||
'sha' => $sha,
|
||||
'message' => $msg,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
return $resp !== null;
|
||||
}
|
||||
|
||||
function apiCall(string $method, string $url, string $token, ?array $data = null): ?array
|
||||
{
|
||||
$headers = [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
|
||||
if ($data !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS,
|
||||
json_encode($data, JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return ($code >= 200 && $code < 300)
|
||||
? (json_decode($body, true) ?: [])
|
||||
: null;
|
||||
}
|
||||
+72
-21
@@ -5,12 +5,11 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_bump.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Auto-increment patch version in README.md — outputs old → new
|
||||
* BRIEF: Auto-increment patch version — checks both README.md and manifest XML, uses the higher version as base
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -23,21 +22,69 @@ foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--major') $type = 'major';
|
||||
}
|
||||
|
||||
$readme = realpath($path) . '/README.md';
|
||||
if (!file_exists($readme)) {
|
||||
fwrite(STDERR, "No README.md found at {$path}\n");
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Read version from README.md ──────────────────────────────────────────────
|
||||
$readmeVersion = null;
|
||||
$readme = "{$root}/README.md";
|
||||
$readmeContent = '';
|
||||
if (file_exists($readme)) {
|
||||
$readmeContent = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read version from Joomla manifest XML ────────────────────────────────────
|
||||
$manifestVersion = null;
|
||||
|
||||
// Check package manifest first (pkg_*.xml), then sub-extension manifests
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Use the higher version as base ───────────────────────────────────────────
|
||||
$baseVersion = null;
|
||||
|
||||
if ($readmeVersion !== null && $manifestVersion !== null) {
|
||||
$baseVersion = version_compare($manifestVersion, $readmeVersion, '>') ? $manifestVersion : $readmeVersion;
|
||||
} elseif ($manifestVersion !== null) {
|
||||
$baseVersion = $manifestVersion;
|
||||
} elseif ($readmeVersion !== null) {
|
||||
$baseVersion = $readmeVersion;
|
||||
}
|
||||
|
||||
if ($baseVersion === null) {
|
||||
fwrite(STDERR, "No version found in README.md or manifest XML\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$content = file_get_contents($readme);
|
||||
if (!preg_match('/^(\s*VERSION:\s*)(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) {
|
||||
fwrite(STDERR, "No VERSION field found in README.md\n");
|
||||
// ── 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)$m[2];
|
||||
$minor = (int)$m[3];
|
||||
$patch = (int)$m[4];
|
||||
$major = (int)$parts[1];
|
||||
$minor = (int)$parts[2];
|
||||
$patch = (int)$parts[3];
|
||||
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
|
||||
switch ($type) {
|
||||
@@ -51,13 +98,17 @@ switch ($type) {
|
||||
}
|
||||
|
||||
$new = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
$updated = preg_replace(
|
||||
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $new,
|
||||
$content,
|
||||
1
|
||||
);
|
||||
|
||||
file_put_contents($readme, $updated);
|
||||
// ── 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);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_bump_remote.php
|
||||
* BRIEF: Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API
|
||||
*
|
||||
* Usage:
|
||||
* php version_bump_remote.php --path . --branch dev --bump minor --token TOKEN --api-base URL
|
||||
* php version_bump_remote.php --path . --branch dev --bump patch --token TOKEN --api-base URL
|
||||
* php version_bump_remote.php --path . --branch dev --bump minor --no-changelog --token TOKEN --api-base URL
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (reads current version from local manifest)
|
||||
* --branch Target branch to bump (required, e.g. dev)
|
||||
* --bump Bump type: patch | minor | major (default: minor)
|
||||
* --token Gitea API token (or GA_TOKEN env var)
|
||||
* --api-base Gitea API base URL for the repo
|
||||
* --no-changelog Skip CHANGELOG.md bump
|
||||
* --repo Repository path (owner/repo) for API base construction
|
||||
* --gitea-url Gitea instance URL (default: env GITEA_URL)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$branch = null;
|
||||
$bumpType = 'minor';
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$noChangelog = false;
|
||||
$repo = null;
|
||||
$giteaUrl = null;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
||||
if ($arg === '--bump' && isset($argv[$i + 1])) $bumpType = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
if ($arg === '--no-changelog') $noChangelog = true;
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
|
||||
}
|
||||
|
||||
if ($token === null) $token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
if ($giteaUrl === null) $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
|
||||
if ($apiBase === null && $repo !== null) {
|
||||
$apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo;
|
||||
}
|
||||
|
||||
if ($branch === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]\n");
|
||||
fwrite(STDERR, " or: version_bump_remote.php --branch BRANCH --token TOKEN --repo owner/repo\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Read current version from local manifest ────────────────────────────
|
||||
$version = null;
|
||||
$manifestFile = null;
|
||||
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $xml, $m)) {
|
||||
if ($version === null || version_compare($m[1], $version, '>')) {
|
||||
$version = $m[1];
|
||||
$manifestFile = basename($f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "No version found in manifest XML\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Compute next version ────────────────────────────────────────────────
|
||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) {
|
||||
fwrite(STDERR, "Invalid version format: {$version}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$major = (int)$parts[1];
|
||||
$minor = (int)$parts[2];
|
||||
$patch = (int)$parts[3];
|
||||
|
||||
switch ($bumpType) {
|
||||
case 'major': $major++; $minor = 0; $patch = 0; break;
|
||||
case 'minor': $minor++; $patch = 0; break;
|
||||
default: $patch++; break;
|
||||
}
|
||||
|
||||
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
echo "{$version} -> {$nextVersion} ({$branch})\n";
|
||||
|
||||
// ── Helper: Gitea API request ───────────────────────────────────────────
|
||||
function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400 || $response === false) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: [];
|
||||
}
|
||||
|
||||
// ── Helper: Update a file on a remote branch ────────────────────────────
|
||||
function updateRemoteFile(
|
||||
string $apiBase,
|
||||
string $token,
|
||||
string $filePath,
|
||||
string $branch,
|
||||
callable $transform,
|
||||
string $commitMessage
|
||||
): bool {
|
||||
$url = "{$apiBase}/contents/{$filePath}?ref={$branch}";
|
||||
$file = giteaApi('GET', $url, $token);
|
||||
if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = base64_decode($file['content']);
|
||||
$newContent = $transform($content);
|
||||
|
||||
if ($newContent === $content) {
|
||||
fwrite(STDERR, " {$filePath}: no changes needed\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => base64_encode($newContent),
|
||||
'sha' => $file['sha'],
|
||||
'message' => $commitMessage,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
|
||||
$result = giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
|
||||
if ($result === null) {
|
||||
fwrite(STDERR, " {$filePath}: failed to update\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
echo " {$filePath}: updated on {$branch}\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Update manifest XML on the remote branch ────────────────────────────
|
||||
$manifestPaths = [];
|
||||
if ($manifestFile !== null) {
|
||||
$manifestPaths[] = "src/{$manifestFile}";
|
||||
}
|
||||
$manifestPaths = array_merge($manifestPaths, [
|
||||
'src/templateDetails.xml',
|
||||
'src/manifest.xml',
|
||||
]);
|
||||
|
||||
$manifestUpdated = false;
|
||||
foreach ($manifestPaths as $mPath) {
|
||||
$result = updateRemoteFile(
|
||||
$apiBase, $token, $mPath, $branch,
|
||||
function (string $content) use ($version, $nextVersion): string {
|
||||
return str_replace(
|
||||
"<version>{$version}</version>",
|
||||
"<version>{$nextVersion}</version>",
|
||||
$content
|
||||
);
|
||||
},
|
||||
"chore(version): bump {$version} -> {$nextVersion} [skip ci]"
|
||||
);
|
||||
if ($result) {
|
||||
$manifestUpdated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$manifestUpdated) {
|
||||
fwrite(STDERR, "WARNING: could not update manifest on {$branch}\n");
|
||||
}
|
||||
|
||||
// ── Update CHANGELOG.md on the remote branch ────────────────────────────
|
||||
if (!$noChangelog) {
|
||||
updateRemoteFile(
|
||||
$apiBase, $token, 'CHANGELOG.md', $branch,
|
||||
function (string $content) use ($version, $nextVersion): string {
|
||||
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
|
||||
|
||||
if (strpos($content, '[Unreleased]') === false
|
||||
&& strpos($content, "## [{$nextVersion}]") === false
|
||||
) {
|
||||
$marker = "## [{$version}]";
|
||||
if (strpos($content, $marker) !== false) {
|
||||
$unreleased = "## [{$nextVersion}] - Unreleased\n\n### Added\n\n### Changed\n\n### Fixed\n\n";
|
||||
$content = str_replace($marker, $unreleased . $marker, $content);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
},
|
||||
"chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]"
|
||||
);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_check.php
|
||||
* VERSION: 05.00.00
|
||||
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
||||
*
|
||||
* Usage:
|
||||
* php version_check.php --path /repo
|
||||
* php version_check.php --path /repo --strict # exit 1 on mismatch
|
||||
* php version_check.php --path /repo --fix # fix mismatches to highest version
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$strict = false;
|
||||
$fix = false;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--strict') $strict = true;
|
||||
if ($arg === '--fix') $fix = true;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$versions = [];
|
||||
|
||||
// ── Read README.md version ───────────────────────────────────────────────────
|
||||
$readme = "{$root}/README.md";
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$versions['README.md'] = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read manifest XML versions ───────────────────────────────────────────────
|
||||
$xmlGlobs = [
|
||||
"{$root}/src/pkg_*.xml",
|
||||
"{$root}/src/*.xml",
|
||||
"{$root}/src/packages/*/*.xml",
|
||||
"{$root}/*.xml",
|
||||
];
|
||||
|
||||
foreach ($xmlGlobs as $glob) {
|
||||
foreach (glob($glob) ?: [] as $file) {
|
||||
// Skip updates.xml
|
||||
if (basename($file) === 'updates.xml') continue;
|
||||
|
||||
$xmlContent = file_get_contents($file);
|
||||
if (strpos($xmlContent, '<extension') === false) continue;
|
||||
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$relPath = str_replace($root . '\\', '', $relPath);
|
||||
$versions[$relPath] = $xm[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($versions)) {
|
||||
fwrite(STDERR, "No version sources found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Compare versions ─────────────────────────────────────────────────────────
|
||||
$uniqueVersions = array_unique(array_values($versions));
|
||||
$highestVersion = '00.00.00';
|
||||
foreach ($versions as $v) {
|
||||
if (version_compare($v, $highestVersion, '>')) {
|
||||
$highestVersion = $v;
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== Version Consistency Check ===\n";
|
||||
foreach ($versions as $source => $ver) {
|
||||
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
|
||||
if ($status === 'MISMATCH') $errors++;
|
||||
echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
|
||||
}
|
||||
|
||||
if (count($uniqueVersions) === 1) {
|
||||
echo "\nAll {$ver} — consistent.\n";
|
||||
} else {
|
||||
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
|
||||
|
||||
if ($fix) {
|
||||
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
|
||||
|
||||
// Fix README.md
|
||||
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
|
||||
$content = file_get_contents($readme);
|
||||
$content = preg_replace(
|
||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $highestVersion,
|
||||
$content,
|
||||
1
|
||||
);
|
||||
file_put_contents($readme, $content);
|
||||
echo " Fixed: README.md -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
// Fix XML manifests
|
||||
foreach ($versions as $source => $ver) {
|
||||
if ($source === 'README.md') continue;
|
||||
if ($ver === $highestVersion) continue;
|
||||
|
||||
$file = "{$root}/{$source}";
|
||||
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";
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($strict && $errors > 0) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
+52
-16
@@ -5,12 +5,11 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_read.php
|
||||
* VERSION: 04.06.00
|
||||
* BRIEF: Read VERSION from README.md — outputs just the version string
|
||||
* BRIEF: Read version from README.md or manifest XML — outputs the higher of the two
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -22,17 +21,54 @@ foreach ($argv as $i => $arg) {
|
||||
}
|
||||
}
|
||||
|
||||
$readme = realpath($path) . '/README.md';
|
||||
if (!file_exists($readme)) {
|
||||
fwrite(STDERR, "No README.md found at {$path}\n");
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Read from README.md ──────────────────────────────────────────────────────
|
||||
$readmeVersion = null;
|
||||
$readme = "{$root}/README.md";
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read from Joomla manifest XML ────────────────────────────────────────────
|
||||
$manifestVersion = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output the higher version ────────────────────────────────────────────────
|
||||
$version = null;
|
||||
if ($readmeVersion !== null && $manifestVersion !== null) {
|
||||
$version = version_compare($manifestVersion, $readmeVersion, '>') ? $manifestVersion : $readmeVersion;
|
||||
} elseif ($manifestVersion !== null) {
|
||||
$version = $manifestVersion;
|
||||
} elseif ($readmeVersion !== null) {
|
||||
$version = $readmeVersion;
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "No version found in README.md or manifest XML\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
echo $m[1] . "\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "No VERSION field found in README.md\n");
|
||||
exit(1);
|
||||
echo $version . "\n";
|
||||
exit(0);
|
||||
|
||||
@@ -5,12 +5,18 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.CLI
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_set_platform.php
|
||||
* VERSION: 04.06.00
|
||||
* 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);
|
||||
@@ -18,10 +24,13 @@ declare(strict_types=1);
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$branch = null;
|
||||
$stability = 'stable';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
|
||||
}
|
||||
|
||||
// Auto-detect branch from git or GitHub env
|
||||
@@ -33,22 +42,54 @@ if ($branch === null) {
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: version_set_platform.php --path . --version development\n");
|
||||
fwrite(STDERR, "Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Append stability suffix for non-stable releases
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
'development' => '-dev',
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
'release-candidate' => '-rc',
|
||||
];
|
||||
$suffix = $stabilitySuffixMap[$stability] ?? '';
|
||||
if ($suffix !== '' && !str_ends_with($version, $suffix)) {
|
||||
$version .= $suffix;
|
||||
echo "Version with stability suffix: {$version}\n";
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Detect platform
|
||||
// Detect platform — check manifest.xml first, then legacy .mokostandards
|
||||
$platform = '';
|
||||
$mokoStandards = "{$root}/.github/.mokostandards";
|
||||
if (!file_exists($mokoStandards)) {
|
||||
$mokoStandards = "{$root}/.mokostandards";
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
if (file_exists($mokoStandards)) {
|
||||
$content = file_get_contents($mokoStandards);
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
$platform = trim($m[1], " \t\n\r\"'");
|
||||
|
||||
// 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\"'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +131,17 @@ if ($platform === 'crm-module') {
|
||||
}
|
||||
}
|
||||
|
||||
// Joomla: <version> in XML manifests
|
||||
if ($platform === 'waas-component') {
|
||||
foreach (glob("{$root}/src/*.xml") ?: glob("{$root}/*.xml") ?: [] as $file) {
|
||||
// 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(
|
||||
@@ -102,7 +151,8 @@ if ($platform === 'waas-component') {
|
||||
);
|
||||
if ($updated !== $content) {
|
||||
file_put_contents($file, $updated);
|
||||
echo "Joomla: " . basename($file) . " → {$version}\n";
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
echo "Joomla: {$relPath} → {$version}\n";
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -2,7 +2,7 @@
|
||||
"name": "mokoconsulting-tech/enterprise",
|
||||
"description": "MokoStandards Enterprise API \u2014 PHP implementation",
|
||||
"type": "library",
|
||||
"version": "04.05.00",
|
||||
"version": "09.00.00",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
@@ -17,7 +17,6 @@
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"mokoconsulting-tech/enterprise": "dev-version/04",
|
||||
"monolog/monolog": "^3.5",
|
||||
"php": ">=8.1",
|
||||
"phpseclib/phpseclib": "^3.0",
|
||||
@@ -74,8 +73,8 @@
|
||||
],
|
||||
"scripts": {
|
||||
"test": "phpunit",
|
||||
"phpcs": "phpcs --standard=phpcs.xml api/",
|
||||
"phpstan": "phpstan analyse -c phpstan.neon api/",
|
||||
"phpcs": "phpcs --standard=phpcs.xml lib/ validate/ automation/",
|
||||
"phpstan": "phpstan analyse -c phpstan.neon lib/ validate/ automation/",
|
||||
"psalm": "psalm --config=psalm.xml",
|
||||
"check": [
|
||||
"@phpcs",
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -86,6 +86,15 @@
|
||||
"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": [
|
||||
@@ -158,7 +167,9 @@
|
||||
"branch-freeze.yml",
|
||||
"changelog-validation.yml",
|
||||
"repository-cleanup.yml",
|
||||
"sync-version-on-merge.yml"
|
||||
"sync-version-on-merge.yml",
|
||||
"cascade-dev.yml",
|
||||
"gitleaks.yml"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
@@ -13,11 +12,11 @@ locals {
|
||||
metadata = {
|
||||
name = "MokoCRM Module"
|
||||
description = "Standard repository structure for MokoCRM (Dolibarr) modules"
|
||||
repository_type = "crm-module"
|
||||
repository_type = "dolibarr"
|
||||
platform = "dolibarr"
|
||||
last_updated = "2026-01-07T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
@@ -167,13 +166,14 @@ EOT
|
||||
audience = "developer"
|
||||
},
|
||||
{
|
||||
name = ".mokostandards"
|
||||
extension = "yml"
|
||||
description = "MokoStandards governance attachment — links this repo back to the standards source"
|
||||
name = ".gitea/.mokostandards"
|
||||
extension = "xml"
|
||||
description = "MokoStandards XML manifest — generated programmatically by RepositorySynchronizer::migrateMokoStandards()"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
template = "templates/configs/mokostandards.yml.template"
|
||||
template = "managed-by-sync"
|
||||
source_type = "programmatic"
|
||||
},
|
||||
{
|
||||
name = "GOVERNANCE.md"
|
||||
@@ -184,6 +184,15 @@ EOT
|
||||
protected = true
|
||||
audience = "all"
|
||||
template = "templates/docs/required/GOVERNANCE.md"
|
||||
},
|
||||
{
|
||||
name = "renovate.json"
|
||||
extension = "json"
|
||||
description = "Renovate dependency management configuration"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
template = "templates/configs/renovate.json"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1092,6 +1101,22 @@ EOT
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/dolibarr/repo_health.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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4,7 +4,6 @@
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
@@ -17,7 +16,7 @@ locals {
|
||||
platform = "multi-platform"
|
||||
last_updated = "2026-01-15T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
@@ -119,13 +118,14 @@ locals {
|
||||
template = "templates/configs/composer.generic.json"
|
||||
},
|
||||
{
|
||||
name = ".mokostandards"
|
||||
extension = "yml"
|
||||
description = "MokoStandards governance attachment — links this repo back to the standards source"
|
||||
name = ".gitea/.mokostandards"
|
||||
extension = "xml"
|
||||
description = "MokoStandards XML manifest — generated programmatically by RepositorySynchronizer::migrateMokoStandards()"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
template = "templates/configs/mokostandards.yml.template"
|
||||
template = "managed-by-sync"
|
||||
source_type = "programmatic"
|
||||
},
|
||||
{
|
||||
name = "GOVERNANCE.md"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
@@ -17,7 +16,7 @@ locals {
|
||||
platform = "multi-platform"
|
||||
last_updated = "2026-01-16T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
@@ -193,6 +192,15 @@ locals {
|
||||
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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -443,6 +451,22 @@ locals {
|
||||
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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -580,24 +604,46 @@ locals {
|
||||
{
|
||||
branch_pattern = "main"
|
||||
require_pull_request = true
|
||||
required_approvals = 1
|
||||
require_code_owner_review = false
|
||||
required_approvals = 0
|
||||
dismiss_stale_reviews = true
|
||||
require_status_checks = true
|
||||
required_status_checks = ["ci", "code-quality"]
|
||||
enforce_admins = false
|
||||
block_on_rejected_reviews = true
|
||||
restrict_pushes = true
|
||||
push_whitelist = ["jmiller"]
|
||||
enable_force_push = true
|
||||
force_push_whitelist = ["jmiller"]
|
||||
enforce_admins = false
|
||||
},
|
||||
{
|
||||
branch_pattern = "master"
|
||||
require_pull_request = true
|
||||
required_approvals = 1
|
||||
require_code_owner_review = false
|
||||
dismiss_stale_reviews = true
|
||||
require_status_checks = true
|
||||
required_status_checks = ["ci"]
|
||||
enforce_admins = false
|
||||
restrict_pushes = true
|
||||
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"]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*
|
||||
* NOTES
|
||||
@@ -28,7 +27,7 @@ locals {
|
||||
platform = "github-private"
|
||||
last_updated = "2026-03-12T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
visibility = "private"
|
||||
sync_priority = -1
|
||||
@@ -116,12 +115,13 @@ locals {
|
||||
audience = "developer"
|
||||
},
|
||||
{
|
||||
name = ".mokostandards.yml"
|
||||
extension = "yml"
|
||||
description = "MokoStandards governance marker — identifies this repo as platform=github-private"
|
||||
name = ".gitea/.mokostandards"
|
||||
extension = "xml"
|
||||
description = "MokoStandards XML manifest — generated programmatically by RepositorySynchronizer::migrateMokoStandards()"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
template = "templates/configs/mokostandards.yml.template"
|
||||
always_overwrite = false
|
||||
template = "managed-by-sync"
|
||||
source_type = "programmatic"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
/**
|
||||
* MokoWaaS Joomla Template Structure Definition
|
||||
* Standard repository structure for Joomla template projects
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.06.10
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
locals {
|
||||
repository_structure = {
|
||||
metadata = {
|
||||
name = "Joomla Template"
|
||||
description = "Standard repository structure for Joomla templates (site or administrator)"
|
||||
repository_type = "joomla-template"
|
||||
platform = "mokowaas"
|
||||
last_updated = "2026-04-14T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.06.10"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
root_files = [
|
||||
{
|
||||
name = "README.md"
|
||||
extension = "md"
|
||||
description = "Developer-focused documentation for contributors and maintainers"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
protected = true
|
||||
audience = "developer"
|
||||
},
|
||||
{
|
||||
name = "LICENSE"
|
||||
extension = ""
|
||||
description = "License file (GPL-3.0-or-later) - Default for Joomla templates"
|
||||
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 vulnerability reporting"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
template = "templates/docs/required/template-SECURITY.md"
|
||||
audience = "general"
|
||||
},
|
||||
{
|
||||
name = "CODE_OF_CONDUCT.md"
|
||||
extension = "md"
|
||||
description = "Community code of conduct"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
template = "templates/docs/extra/template-CODE_OF_CONDUCT.md"
|
||||
audience = "contributor"
|
||||
},
|
||||
{
|
||||
name = "CONTRIBUTING.md"
|
||||
extension = "md"
|
||||
description = "Contribution guidelines"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
template = "templates/docs/required/template-CONTRIBUTING.md"
|
||||
audience = "contributor"
|
||||
},
|
||||
{
|
||||
name = "templateDetails.xml"
|
||||
extension = "xml"
|
||||
description = "Joomla template manifest — declares template metadata, positions, styles, and dependencies"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
stub_content = <<-MOKO_END
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="template" client="site" method="upgrade">
|
||||
<name>{{TEMPLATE_NAME}}</name>
|
||||
<creationDate>{{CREATION_DATE}}</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<version>{{VERSION}}</version>
|
||||
<description>{{REPO_DESCRIPTION}}</description>
|
||||
|
||||
<files>
|
||||
<filename>index.php</filename>
|
||||
<filename>component.php</filename>
|
||||
<filename>error.php</filename>
|
||||
<filename>offline.php</filename>
|
||||
<filename>templateDetails.xml</filename>
|
||||
<folder>html</folder>
|
||||
<folder>css</folder>
|
||||
<folder>js</folder>
|
||||
<folder>images</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<media destination="templates/site/{{TEMPLATE_SHORT_NAME}}" folder="media">
|
||||
<folder>css</folder>
|
||||
<folder>js</folder>
|
||||
<folder>images</folder>
|
||||
<folder>scss</folder>
|
||||
</media>
|
||||
|
||||
<positions>
|
||||
<position>topbar</position>
|
||||
<position>navbar</position>
|
||||
<position>hero</position>
|
||||
<position>breadcrumbs</position>
|
||||
<position>sidebar-left</position>
|
||||
<position>sidebar-right</position>
|
||||
<position>main-top</position>
|
||||
<position>main-bottom</position>
|
||||
<position>footer</position>
|
||||
<position>debug</position>
|
||||
</positions>
|
||||
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="{{TEMPLATE_NAME}} Update Server">
|
||||
https://git.mokoconsulting.tech/MokoConsulting/{{REPO_NAME}}/raw/branch/main/updates.xml
|
||||
</server>
|
||||
<server type="extension" priority="2" name="{{TEMPLATE_NAME}} Update Server">
|
||||
https://raw.githubusercontent.com/mokoconsulting-tech/{{REPO_NAME}}/main/updates.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic">
|
||||
<field name="logoFile" type="media" label="Logo" />
|
||||
<field name="siteTitle" type="text" label="Site Title" default="" />
|
||||
<field name="siteDescription" type="text" label="Site Description" default="" />
|
||||
<field name="colorScheme" type="list" label="Color Scheme" default="light">
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="auto">Auto (system preference)</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="advanced">
|
||||
<field name="fluidContainer" type="radio" label="Fluid Container" default="0">
|
||||
<option value="0">No</option>
|
||||
<option value="1">Yes</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
MOKO_END
|
||||
},
|
||||
{
|
||||
name = "updates.xml"
|
||||
extension = "xml"
|
||||
description = "Joomla template update server manifest — polled by Joomla for new versions (dual-platform: Gitea priority 1, GitHub priority 2)"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
stub_content = <<-MOKO_END
|
||||
<updates>
|
||||
<update>
|
||||
<name>{{TEMPLATE_NAME}}</name>
|
||||
<description>{{REPO_DESCRIPTION}}</description>
|
||||
<element>tpl_{{TEMPLATE_SHORT_NAME}}</element>
|
||||
<type>template</type>
|
||||
<version>{{VERSION}}</version>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">
|
||||
https://git.mokoconsulting.tech/mokoconsulting-tech/{{REPO_NAME}}/releases/download/v{{VERSION}}/{{TEMPLATE_SHORT_NAME}}.zip
|
||||
</downloadurl>
|
||||
<downloadurl type="full" format="zip">
|
||||
https://github.com/mokoconsulting-tech/{{REPO_NAME}}/releases/download/v{{VERSION}}/{{TEMPLATE_SHORT_NAME}}.zip
|
||||
</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="[56].*"/>
|
||||
<php_minimum>8.1</php_minimum>
|
||||
</update>
|
||||
</updates>
|
||||
MOKO_END
|
||||
},
|
||||
{
|
||||
name = "phpstan.neon"
|
||||
extension = "neon"
|
||||
description = "PHPStan static analysis config with Joomla framework class stubs"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
audience = "developer"
|
||||
template = "templates/configs/phpstan.joomla.neon"
|
||||
},
|
||||
{
|
||||
name = "Makefile"
|
||||
description = "Build automation for Joomla template packaging"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
audience = "developer"
|
||||
template = "templates/makefiles/Makefile.joomla.template"
|
||||
},
|
||||
{
|
||||
name = ".gitignore"
|
||||
extension = "gitignore"
|
||||
description = "Git ignore patterns for Joomla template development"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
template = "templates/configs/.gitignore.joomla"
|
||||
},
|
||||
{
|
||||
name = ".editorconfig"
|
||||
extension = "editorconfig"
|
||||
description = "Editor configuration for consistent coding style"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
template = "templates/configs/.editorconfig"
|
||||
audience = "developer"
|
||||
},
|
||||
{
|
||||
name = ".ftpignore"
|
||||
extension = "ftpignore"
|
||||
description = "FTP/SFTP ignore patterns for deploy-joomla.php"
|
||||
required = false
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
},
|
||||
]
|
||||
|
||||
subdirectories = [
|
||||
{
|
||||
name = "src"
|
||||
description = "Template source files — maps to templates/{name}/ on the Joomla server"
|
||||
required = true
|
||||
files = [
|
||||
{
|
||||
name = "index.php"
|
||||
description = "Main template entry point"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
stub_content = <<-MOKO_END
|
||||
<?php
|
||||
/**
|
||||
* @package Joomla.Site
|
||||
* @subpackage Templates.{{TEMPLATE_SHORT_NAME}}
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Document\HtmlDocument;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
|
||||
/** @var HtmlDocument $this */
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$wa = $this->getWebAssetManager();
|
||||
$doc = $app->getDocument();
|
||||
$lang = $app->getLanguage();
|
||||
|
||||
// Load template CSS/JS via Web Asset Manager
|
||||
$wa->useStyle('template.{{TEMPLATE_SHORT_NAME}}.css');
|
||||
$wa->useScript('template.{{TEMPLATE_SHORT_NAME}}.js');
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
|
||||
<head>
|
||||
<jdoc:include type="metas" />
|
||||
<jdoc:include type="styles" />
|
||||
<jdoc:include type="scripts" />
|
||||
</head>
|
||||
<body class="site <?php echo $this->direction === 'rtl' ? 'rtl' : 'ltr'; ?>">
|
||||
<header>
|
||||
<jdoc:include type="modules" name="topbar" style="none" />
|
||||
<jdoc:include type="modules" name="navbar" style="none" />
|
||||
</header>
|
||||
|
||||
<jdoc:include type="modules" name="hero" style="none" />
|
||||
<jdoc:include type="modules" name="breadcrumbs" style="none" />
|
||||
|
||||
<main>
|
||||
<jdoc:include type="modules" name="main-top" style="html5" />
|
||||
<jdoc:include type="message" />
|
||||
<jdoc:include type="component" />
|
||||
<jdoc:include type="modules" name="main-bottom" style="html5" />
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<jdoc:include type="modules" name="footer" style="none" />
|
||||
</footer>
|
||||
|
||||
<jdoc:include type="modules" name="debug" style="none" />
|
||||
</body>
|
||||
</html>
|
||||
MOKO_END
|
||||
},
|
||||
{
|
||||
name = "error.php"
|
||||
description = "Error page template (404, 500, etc.)"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
},
|
||||
{
|
||||
name = "offline.php"
|
||||
description = "Offline page template shown when site is in maintenance mode"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
},
|
||||
{
|
||||
name = "component.php"
|
||||
description = "Component-only template (print view / raw output)"
|
||||
required = false
|
||||
always_overwrite = false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name = "src/html"
|
||||
description = "Template overrides for Joomla component/module views"
|
||||
required = true
|
||||
files = [
|
||||
{
|
||||
name = "index.html"
|
||||
description = "Prevents directory listing"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
stub_content = "<!DOCTYPE html><title></title>"
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name = "src/css"
|
||||
description = "Compiled CSS files"
|
||||
required = true
|
||||
files = []
|
||||
},
|
||||
{
|
||||
name = "src/js"
|
||||
description = "JavaScript files"
|
||||
required = true
|
||||
files = []
|
||||
},
|
||||
{
|
||||
name = "src/images"
|
||||
description = "Template images and icons"
|
||||
required = true
|
||||
files = [
|
||||
{
|
||||
name = "template_preview.png"
|
||||
description = "Template preview screenshot shown in Joomla admin"
|
||||
required = false
|
||||
always_overwrite = false
|
||||
},
|
||||
{
|
||||
name = "template_thumbnail.png"
|
||||
description = "Template thumbnail shown in template list"
|
||||
required = false
|
||||
always_overwrite = false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name = "src/language/en-GB"
|
||||
description = "English language files for the template"
|
||||
required = true
|
||||
files = [
|
||||
{
|
||||
name = "tpl_{{TEMPLATE_SHORT_NAME}}.ini"
|
||||
description = "Main language strings"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
},
|
||||
{
|
||||
name = "tpl_{{TEMPLATE_SHORT_NAME}}.sys.ini"
|
||||
description = "System language strings (used in admin template manager)"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name = "media"
|
||||
description = "Template media files — maps to media/templates/site/{name}/ on the Joomla server"
|
||||
required = true
|
||||
files = []
|
||||
},
|
||||
{
|
||||
name = "media/css"
|
||||
description = "Compiled CSS assets served from /media/"
|
||||
required = true
|
||||
files = []
|
||||
},
|
||||
{
|
||||
name = "media/js"
|
||||
description = "JavaScript assets served from /media/"
|
||||
required = true
|
||||
files = []
|
||||
},
|
||||
{
|
||||
name = "media/images"
|
||||
description = "Image assets served from /media/"
|
||||
required = true
|
||||
files = []
|
||||
},
|
||||
{
|
||||
name = "media/scss"
|
||||
description = "SCSS source files (compiled to media/css/)"
|
||||
required = false
|
||||
files = []
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
@@ -13,11 +12,11 @@ locals {
|
||||
metadata = {
|
||||
name = "MokoWaaS Component"
|
||||
description = "Standard repository structure for MokoWaaS (Joomla) components"
|
||||
repository_type = "waas-component"
|
||||
repository_type = "joomla"
|
||||
platform = "mokowaas"
|
||||
last_updated = "2026-01-15T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
@@ -83,13 +82,13 @@ locals {
|
||||
audience = "contributor"
|
||||
},
|
||||
{
|
||||
name = "update.xml"
|
||||
name = "updates.xml"
|
||||
extension = "xml"
|
||||
description = "Joomla extension update server manifest — lists releases for Joomla auto-update; must be kept in sync with manifest.xml version"
|
||||
description = "Joomla extension update server manifest — lists releases for Joomla auto-update; managed by release workflow, never overwritten by sync"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
protected = true
|
||||
audience = "developer"
|
||||
template = "templates/joomla/update.xml.template"
|
||||
stub_content = <<-MOKO_END
|
||||
<!--
|
||||
Joomla Extension Update Server XML
|
||||
@@ -101,10 +100,10 @@ locals {
|
||||
The manifest.xml in this repository must reference this file:
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="{{EXTENSION_NAME}}">
|
||||
https://git.mokoconsulting.tech/mokoconsulting-tech/{{REPO_NAME}}/raw/branch/main/update.xml
|
||||
https://git.mokoconsulting.tech/mokoconsulting-tech/{{REPO_NAME}}/raw/branch/main/updates.xml
|
||||
</server>
|
||||
<server type="extension" priority="2" name="{{EXTENSION_NAME}}">
|
||||
https://raw.githubusercontent.com/mokoconsulting-tech/{{REPO_NAME}}/main/update.xml
|
||||
https://git.mokoconsulting.tech/MokoConsulting/{{REPO_NAME}}/raw/branch/main/updates.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
|
||||
@@ -123,7 +122,7 @@ locals {
|
||||
https://git.mokoconsulting.tech/mokoconsulting-tech/{{REPO_NAME}}/releases/download/v{{VERSION}}/{{EXTENSION_ELEMENT}}.zip
|
||||
</downloadurl>
|
||||
<downloadurl type="full" format="zip">
|
||||
https://github.com/mokoconsulting-tech/{{REPO_NAME}}/releases/download/v{{VERSION}}/{{EXTENSION_ELEMENT}}.zip
|
||||
https://git.mokoconsulting.tech/MokoConsulting/{{REPO_NAME}}/releases/download/v{{VERSION}}/{{EXTENSION_ELEMENT}}.zip
|
||||
</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="[56].*"/>
|
||||
@@ -221,13 +220,14 @@ locals {
|
||||
template = "templates/configs/composer.joomla.json"
|
||||
},
|
||||
{
|
||||
name = ".mokostandards"
|
||||
extension = "yml"
|
||||
description = "MokoStandards governance attachment — links this repo back to the standards source"
|
||||
name = ".gitea/.mokostandards"
|
||||
extension = "xml"
|
||||
description = "MokoStandards XML manifest — generated programmatically by RepositorySynchronizer::migrateMokoStandards()"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
template = "templates/configs/mokostandards.yml.template"
|
||||
template = "managed-by-sync"
|
||||
source_type = "programmatic"
|
||||
},
|
||||
{
|
||||
name = "GOVERNANCE.md"
|
||||
@@ -238,6 +238,15 @@ locals {
|
||||
protected = true
|
||||
audience = "all"
|
||||
template = "templates/docs/required/GOVERNANCE.md"
|
||||
},
|
||||
{
|
||||
name = "renovate.json"
|
||||
extension = "json"
|
||||
description = "Renovate dependency management configuration"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
template = "templates/configs/renovate.json"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -377,7 +386,7 @@ locals {
|
||||
{
|
||||
name = "update-server.md"
|
||||
extension = "md"
|
||||
description = "Joomla update server (update.xml) documentation"
|
||||
description = "Joomla update server (updates.xml) documentation"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
template = "templates/docs/required/template-update-server-joomla.md"
|
||||
@@ -425,7 +434,7 @@ locals {
|
||||
{
|
||||
name = ".github"
|
||||
path = ".github"
|
||||
description = "GitHub-specific configuration"
|
||||
description = "Gitea/GitHub Actions configuration (Gitea reads .github/workflows natively)"
|
||||
requirement_status = "suggested"
|
||||
purpose = "Contains GitHub Actions workflows and configuration"
|
||||
files = [
|
||||
@@ -467,7 +476,7 @@ locals {
|
||||
> | Placeholder | Where to find the value |
|
||||
> |---|---|
|
||||
> | `{{REPO_NAME}}` | The GitHub repository name (visible in the URL, `README.md` heading, or `git remote -v`) |
|
||||
> | `{{REPO_URL}}` | Full GitHub URL, e.g. `https://github.com/mokoconsulting-tech/<repo-name>` |
|
||||
> | `{{REPO_URL}}` | Full Gitea URL, e.g. `https://git.mokoconsulting.tech/MokoConsulting/<repo-name>` |
|
||||
> | `{{EXTENSION_NAME}}` | The `<name>` element in `manifest.xml` at the repository root |
|
||||
> | `{{EXTENSION_TYPE}}` | The `type` attribute of the `<extension>` tag in `manifest.xml` (`component`, `module`, `plugin`, or `template`) |
|
||||
> | `{{EXTENSION_ELEMENT}}` | The `<element>` tag in `manifest.xml`, or the filename prefix (e.g. `com_myextension`, `mod_mymodule`) |
|
||||
@@ -478,7 +487,7 @@ locals {
|
||||
|
||||
## What This Repo Is
|
||||
|
||||
This is a **Moko Consulting MokoWaaS** (Joomla) repository governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). All coding standards, workflows, and policies are defined there and enforced here via bulk sync.
|
||||
This is a **Moko Consulting MokoWaaS** (Joomla) repository governed by [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards). All coding standards, workflows, and policies are defined there and enforced here via bulk sync.
|
||||
|
||||
Repository URL: {{REPO_URL}}
|
||||
Extension name: **{{EXTENSION_NAME}}**
|
||||
@@ -552,13 +561,13 @@ locals {
|
||||
|
||||
### Joomla Version Alignment
|
||||
|
||||
The version in `README.md` **must always match** the `<version>` tag in `manifest.xml` and the latest entry in `update.xml`. The `make release` command / release workflow updates all three automatically.
|
||||
The version in `README.md` **must always match** the `<version>` tag in `manifest.xml` and the latest entry in `updates.xml`. The `make release` command / release workflow updates all three automatically.
|
||||
|
||||
```xml
|
||||
<!-- In manifest.xml — must match README.md version -->
|
||||
<version>01.02.04</version>
|
||||
|
||||
<!-- In update.xml — prepend a new <update> block for every release.
|
||||
<!-- In updates.xml — prepend a new <update> block for every release.
|
||||
The version="[56].*" regex matches Joomla 5.x and 6.x. -->
|
||||
<updates>
|
||||
<update>
|
||||
@@ -582,7 +591,7 @@ locals {
|
||||
```
|
||||
{{REPO_NAME}}/
|
||||
├── manifest.xml # Joomla installer manifest (root — required)
|
||||
├── update.xml # Update server manifest (root — required, see below)
|
||||
├── updates.xml # Update server manifest (root — required, see below)
|
||||
├── site/ # Frontend (site) code
|
||||
│ ├── controller.php
|
||||
│ ├── controllers/
|
||||
@@ -611,22 +620,22 @@ locals {
|
||||
|
||||
---
|
||||
|
||||
## update.xml — Required in Repo Root
|
||||
## updates.xml — Required in Repo Root
|
||||
|
||||
`update.xml` **must exist at the repository root**. It is the Joomla update server manifest that allows Joomla installations to check for new versions of this extension.
|
||||
`updates.xml` **must exist at the repository root**. It is the Joomla update server manifest that allows Joomla installations to check for new versions of this extension.
|
||||
|
||||
The `manifest.xml` must reference it via:
|
||||
```xml
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="{{EXTENSION_NAME}}">
|
||||
{{REPO_URL}}/raw/main/update.xml
|
||||
{{REPO_URL}}/raw/main/updates.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Every release must prepend a new `<update>` block at the top of `update.xml` — old entries must be preserved below.
|
||||
- The `<version>` in `update.xml` must exactly match `<version>` in `manifest.xml` and the version in `README.md`.
|
||||
- Every release must prepend a new `<update>` block at the top of `updates.xml` — old entries must be preserved below.
|
||||
- The `<version>` in `updates.xml` must exactly match `<version>` in `manifest.xml` and the version in `README.md`.
|
||||
- The `<downloadurl>` must be a publicly accessible direct download link (GitHub Releases asset URL).
|
||||
- `<targetplatform name="joomla" version="[56].*">` — Joomla treats the version value as a regex; `[56].*` matches Joomla 5.x and 6.x.
|
||||
|
||||
@@ -635,8 +644,8 @@ locals {
|
||||
## manifest.xml Rules
|
||||
|
||||
- Lives at the repo root as `manifest.xml` (not inside `site/` or `admin/`).
|
||||
- `<version>` tag must be kept in sync with `README.md` version and `update.xml`.
|
||||
- Must include `<updateservers>` block pointing to this repo's `update.xml`.
|
||||
- `<version>` tag must be kept in sync with `README.md` version and `updates.xml`.
|
||||
- Must include `<updateservers>` block pointing to this repo's `updates.xml`.
|
||||
- Must include `<files folder="site">` and `<administration>` sections.
|
||||
- Joomla 4.x requires `<namespace path="src">Moko\{{EXTENSION_NAME}}</namespace>` for namespaced extensions.
|
||||
|
||||
@@ -644,16 +653,16 @@ locals {
|
||||
|
||||
## GitHub Actions — Token Usage
|
||||
|
||||
Every workflow must use **`secrets.GH_TOKEN`** (the org-level Personal Access Token).
|
||||
Every workflow must use **`secrets.GA_TOKEN`** (the Gitea API token). Use `secrets.GH_TOKEN` only for GitHub mirror operations (stable/RC releases).
|
||||
|
||||
```yaml
|
||||
# ✅ Correct
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
```
|
||||
|
||||
```yaml
|
||||
@@ -666,16 +675,16 @@ locals {
|
||||
|
||||
## MokoStandards Reference
|
||||
|
||||
This repository is governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). Authoritative policies:
|
||||
This repository is governed by [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards). Authoritative policies:
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [file-header-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/file-header-standards.md) | Copyright-header rules for every file type |
|
||||
| [coding-style-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/coding-style-guide.md) | Naming and formatting conventions |
|
||||
| [branching-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branching-strategy.md) | Branch naming, hierarchy, and release workflow |
|
||||
| [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) | Squash-merge policy and PR title/body conventions |
|
||||
| [changelog-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/changelog-standards.md) | How and when to update CHANGELOG.md |
|
||||
| [joomla-development-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/guide/waas/joomla-development-guide.md) | MokoWaaS Joomla extension development guide |
|
||||
| [file-header-standards.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/file-header-standards.md) | Copyright-header rules for every file type |
|
||||
| [coding-style-guide.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/coding-style-guide.md) | Naming and formatting conventions |
|
||||
| [branching-strategy.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/branching-strategy.md) | Branch naming, hierarchy, and release workflow |
|
||||
| [merge-strategy.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/merge-strategy.md) | Squash-merge policy and PR title/body conventions |
|
||||
| [changelog-standards.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/changelog-standards.md) | How and when to update CHANGELOG.md |
|
||||
| [joomla-development-guide.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/guide/waas/joomla-development-guide.md) | MokoWaaS Joomla extension development guide |
|
||||
|
||||
---
|
||||
|
||||
@@ -714,8 +723,8 @@ locals {
|
||||
| Change type | Documentation to update |
|
||||
|-------------|------------------------|
|
||||
| New or renamed PHP class/method | PHPDoc block; `docs/api/` entry |
|
||||
| New or changed manifest.xml | Update `update.xml` version; bump README.md version |
|
||||
| New release | Prepend `<update>` block to `update.xml`; update CHANGELOG.md; bump README.md version |
|
||||
| New or changed manifest.xml | Update `updates.xml` version; bump README.md version |
|
||||
| New release | Prepend `<update>` block to `updates.xml`; update CHANGELOG.md; bump README.md version |
|
||||
| New or changed workflow | `docs/workflows/<workflow-name>.md` |
|
||||
| Any modified file | Update the `VERSION` field in that file's `FILE INFORMATION` block |
|
||||
| **Every PR** | **Bump the patch version** — increment `XX.YY.ZZ` in `README.md`; `sync-version-on-merge` propagates it |
|
||||
@@ -728,8 +737,8 @@ locals {
|
||||
- Never skip the FILE INFORMATION block on a new file
|
||||
- Never add `defined('_JEXEC') or die;` to CLI scripts or model tests — only to web-accessible PHP files
|
||||
- Never hardcode version numbers in body text — update `README.md` and let automation propagate
|
||||
- Never use `github.token` or `secrets.GITHUB_TOKEN` in workflows — always use `secrets.GH_TOKEN`
|
||||
- Never let `manifest.xml` version, `update.xml` version, and `README.md` version go out of sync
|
||||
- Use `secrets.GA_TOKEN` for Gitea operations. Use `secrets.GH_TOKEN` only for GitHub mirror (stable/RC). Never use `secrets.GITHUB_TOKEN` directly
|
||||
- Never let `manifest.xml` version, `updates.xml` version, and `README.md` version go out of sync
|
||||
MOKO_END
|
||||
},
|
||||
{
|
||||
@@ -762,7 +771,7 @@ locals {
|
||||
> | Placeholder | Where to find the value |
|
||||
> |---|---|
|
||||
> | `{{REPO_NAME}}` | The GitHub repository name (visible in the URL, `README.md` heading, or `git remote -v`) |
|
||||
> | `{{REPO_URL}}` | Full GitHub URL, e.g. `https://github.com/mokoconsulting-tech/<repo-name>` |
|
||||
> | `{{REPO_URL}}` | Full Gitea URL, e.g. `https://git.mokoconsulting.tech/MokoConsulting/<repo-name>` |
|
||||
> | `{{REPO_DESCRIPTION}}` | First paragraph of `README.md` body, or the GitHub repo description |
|
||||
> | `{{EXTENSION_NAME}}` | The `<name>` element in `manifest.xml` at the repository root |
|
||||
> | `{{EXTENSION_TYPE}}` | The `type` attribute of the `<extension>` tag in `manifest.xml` (`component`, `module`, `plugin`, or `template`) |
|
||||
@@ -780,7 +789,7 @@ locals {
|
||||
Extension type: **{{EXTENSION_TYPE}}** (`{{EXTENSION_ELEMENT}}`)
|
||||
Repository URL: {{REPO_URL}}
|
||||
|
||||
This repository is governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards) — the single source of truth for coding standards, file-header policies, GitHub Actions workflows, and Terraform configuration templates across all Moko Consulting repositories.
|
||||
This repository is governed by [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards) — the single source of truth for coding standards, file-header policies, GitHub Actions workflows, and Terraform configuration templates across all Moko Consulting repositories.
|
||||
|
||||
---
|
||||
|
||||
@@ -789,7 +798,7 @@ locals {
|
||||
```
|
||||
{{REPO_NAME}}/
|
||||
├── manifest.xml # Joomla installer manifest (root — required)
|
||||
├── update.xml # Update server manifest (root — required)
|
||||
├── updates.xml # Update server manifest (root — required)
|
||||
├── site/ # Frontend (site) code
|
||||
│ ├── controller.php
|
||||
│ ├── controllers/
|
||||
@@ -839,32 +848,32 @@ locals {
|
||||
|------|------------------------|
|
||||
| `README.md` | `FILE INFORMATION` block + badge |
|
||||
| `manifest.xml` | `<version>` tag |
|
||||
| `update.xml` | `<version>` in the most recent `<update>` block |
|
||||
| `updates.xml` | `<version>` in the most recent `<update>` block |
|
||||
|
||||
The `make release` command / release workflow syncs all three automatically.
|
||||
|
||||
---
|
||||
|
||||
# update.xml — Required in Repo Root
|
||||
# updates.xml — Required in Repo Root
|
||||
|
||||
`update.xml` is the Joomla update server manifest. It allows Joomla installations to check for new versions of this extension via:
|
||||
`updates.xml` is the Joomla update server manifest. It allows Joomla installations to check for new versions of this extension via:
|
||||
|
||||
```xml
|
||||
<!-- In manifest.xml -->
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="{{EXTENSION_NAME}}">
|
||||
{{REPO_URL}}/raw/main/update.xml
|
||||
{{REPO_URL}}/raw/main/updates.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Every release prepends a new `<update>` block at the top — older entries are preserved.
|
||||
- `<version>` in `update.xml` must exactly match `<version>` in `manifest.xml` and `README.md`.
|
||||
- `<version>` in `updates.xml` must exactly match `<version>` in `manifest.xml` and `README.md`.
|
||||
- `<downloadurl>` must be a publicly accessible GitHub Releases asset URL.
|
||||
- `<targetplatform version="[56].*">` — Joomla treats the version value as a regex; `[56].*` matches Joomla 5.x and 6.x.
|
||||
|
||||
Example `update.xml` entry for a new release:
|
||||
Example `updates.xml` entry for a new release:
|
||||
```xml
|
||||
<updates>
|
||||
<update>
|
||||
@@ -948,16 +957,16 @@ locals {
|
||||
|
||||
# GitHub Actions — Token Usage
|
||||
|
||||
Every workflow must use **`secrets.GH_TOKEN`** (the org-level Personal Access Token).
|
||||
Every workflow must use **`secrets.GA_TOKEN`** (the Gitea API token). Use `secrets.GH_TOKEN` only for GitHub mirror operations (stable/RC releases).
|
||||
|
||||
```yaml
|
||||
# ✅ Correct
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
```
|
||||
|
||||
```yaml
|
||||
@@ -973,8 +982,8 @@ locals {
|
||||
| Change type | Documentation to update |
|
||||
|-------------|------------------------|
|
||||
| New or renamed PHP class/method | PHPDoc block; `docs/api/` entry |
|
||||
| New or changed `manifest.xml` | Sync version to `update.xml` and `README.md` |
|
||||
| New release | Prepend `<update>` to `update.xml`; update `CHANGELOG.md`; bump `README.md` |
|
||||
| New or changed `manifest.xml` | Sync version to `updates.xml` and `README.md` |
|
||||
| New release | Prepend `<update>` to `updates.xml`; update `CHANGELOG.md`; bump `README.md` |
|
||||
| New or changed workflow | `docs/workflows/<workflow-name>.md` |
|
||||
| Any modified file | Update the `VERSION` field in that file's `FILE INFORMATION` block |
|
||||
| **Every PR** | **Bump the patch version** — increment `XX.YY.ZZ` in `README.md`; `sync-version-on-merge` propagates it |
|
||||
@@ -985,7 +994,7 @@ locals {
|
||||
|
||||
- **Never commit directly to `main`** — all changes go through a PR.
|
||||
- **Never hardcode version numbers** in body text — update `README.md` and let automation propagate.
|
||||
- **Never let `manifest.xml`, `update.xml`, and `README.md` versions diverge.**
|
||||
- **Never let `manifest.xml`, `updates.xml`, and `README.md` versions diverge.**
|
||||
- **Never skip the FILE INFORMATION block** on a new source file.
|
||||
- **Never use bare `catch (\Throwable $e) {}`** — always log or re-throw.
|
||||
- **Never mix tabs and spaces** within a file — follow `.editorconfig`.
|
||||
@@ -999,7 +1008,7 @@ locals {
|
||||
Before opening a PR, verify:
|
||||
|
||||
- [ ] Patch version bumped in `README.md` (e.g. `01.02.03` → `01.02.04`)
|
||||
- [ ] If this is a release: `manifest.xml` version updated; `update.xml` updated with new entry
|
||||
- [ ] If this is a release: `manifest.xml` version updated; `updates.xml` updated with new entry
|
||||
- [ ] FILE INFORMATION headers updated in modified files
|
||||
- [ ] CHANGELOG.md updated
|
||||
- [ ] Tests pass
|
||||
@@ -1010,117 +1019,125 @@ locals {
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [file-header-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/file-header-standards.md) | Copyright-header rules for every file type |
|
||||
| [coding-style-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/coding-style-guide.md) | Naming and formatting conventions |
|
||||
| [branching-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branching-strategy.md) | Branch naming, hierarchy, and release workflow |
|
||||
| [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) | Squash-merge policy and PR conventions |
|
||||
| [changelog-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/changelog-standards.md) | How and when to update CHANGELOG.md |
|
||||
| [joomla-development-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/guide/waas/joomla-development-guide.md) | MokoWaaS Joomla extension development guide |
|
||||
| [file-header-standards.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/file-header-standards.md) | Copyright-header rules for every file type |
|
||||
| [coding-style-guide.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/coding-style-guide.md) | Naming and formatting conventions |
|
||||
| [branching-strategy.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/branching-strategy.md) | Branch naming, hierarchy, and release workflow |
|
||||
| [merge-strategy.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/merge-strategy.md) | Squash-merge policy and PR conventions |
|
||||
| [changelog-standards.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/policy/changelog-standards.md) | How and when to update CHANGELOG.md |
|
||||
| [joomla-development-guide.md](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/blob/main/docs/guide/waas/joomla-development-guide.md) | MokoWaaS Joomla extension development guide |
|
||||
MOKO_END
|
||||
}
|
||||
]
|
||||
subdirectories = [
|
||||
{
|
||||
name = "workflows"
|
||||
path = ".github/workflows"
|
||||
description = "GitHub Actions workflows"
|
||||
path = ".gitea/workflows"
|
||||
description = "Gitea Actions CI/CD workflows"
|
||||
requirement_status = "required"
|
||||
files = [
|
||||
{
|
||||
name = "ci-joomla.yml"
|
||||
extension = "yml"
|
||||
description = "Joomla-specific CI workflow"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/joomla/ci-joomla.yml.template"
|
||||
},
|
||||
{
|
||||
name = "codeql-analysis.yml"
|
||||
extension = "yml"
|
||||
description = "CodeQL security analysis workflow"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/generic/codeql-analysis.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 = "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"
|
||||
description = "Automated release — builds zip, creates Gitea release, updates SHA in updates.xml. Triggered by push to main (stable) or pre-release tags"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/auto-release.yml.template"
|
||||
template = "workflows/auto-release.yml"
|
||||
},
|
||||
{
|
||||
name = "repository-cleanup.yml"
|
||||
name = "ci-dolibarr.yml"
|
||||
extension = "yml"
|
||||
description = "Scheduled cleanup: delete retired workflows, stale branches, old workflow runs"
|
||||
description = "Continuous integration — PHP linting, PHPStan static analysis, Dolibarr module validation"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/repository-cleanup.yml.template"
|
||||
template = "workflows/ci-dolibarr.yml"
|
||||
},
|
||||
{
|
||||
name = "auto-dev-issue.yml"
|
||||
name = "publish-to-mokodolimods.yml"
|
||||
extension = "yml"
|
||||
description = "Auto-create tracking issue when a dev/** branch is pushed"
|
||||
description = "On release, copies src/ into htdocs/custom/ in mokodolimods repo and opens a PR"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/auto-dev-issue.yml.template"
|
||||
template = "workflows/publish-to-mokodolimods.yml"
|
||||
},
|
||||
{
|
||||
name = "repo_health.yml"
|
||||
name = "pre-release.yml"
|
||||
extension = "yml"
|
||||
description = "Joomla-specific repository health check workflow"
|
||||
description = "Manual pre-release — builds dev/alpha/beta/rc packages with patch version bump"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/joomla/repo_health.yml.template"
|
||||
template = "workflows/pre-release.yml"
|
||||
},
|
||||
{
|
||||
name = "deploy-manual.yml"
|
||||
extension = "yml"
|
||||
description = "Manual deployment — allows selecting target environment and branch for on-demand deploys"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "workflows/deploy-manual.yml"
|
||||
},
|
||||
{
|
||||
name = "repo-health.yml"
|
||||
extension = "yml"
|
||||
description = "Repository health checks — validates required files, structure compliance, and standards alignment"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "workflows/repo-health.yml"
|
||||
},
|
||||
{
|
||||
name = "update-server.yml"
|
||||
extension = "yml"
|
||||
description = "Update server maintenance — validates updates.xml format and ensures download URLs are reachable"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "workflows/update-server.yml"
|
||||
},
|
||||
{
|
||||
name = "pr-check.yml"
|
||||
extension = "yml"
|
||||
description = "PR gate — validates PHP syntax, manifest XML, and package build before merge to main"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "workflows/pr-check.yml"
|
||||
},
|
||||
{
|
||||
name = "security-audit.yml"
|
||||
extension = "yml"
|
||||
description = "Dependency vulnerability scanning — weekly schedule and on PR when lock files change"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "workflows/security-audit.yml"
|
||||
},
|
||||
{
|
||||
name = "notify.yml"
|
||||
extension = "yml"
|
||||
description = "Push notifications via ntfy on release success or workflow failure"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "workflows/notify.yml"
|
||||
},
|
||||
{
|
||||
name = "cleanup.yml"
|
||||
extension = "yml"
|
||||
description = "Scheduled cleanup — delete merged branches and old workflow runs weekly"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "workflows/cleanup.yml"
|
||||
},
|
||||
{
|
||||
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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* MCP Server Repository Structure Definition
|
||||
* Standard repository structure for Model Context Protocol (MCP) server projects
|
||||
*
|
||||
* 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 = "MCP Server"
|
||||
description = "Standard repository structure for Model Context Protocol (MCP) server projects — TypeScript/Node.js MCP servers that expose external APIs as AI assistant tools"
|
||||
repository_type = "mcp-server"
|
||||
platform = "mcp-server"
|
||||
last_updated = "2026-05-07T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.06.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
root_files = [
|
||||
{
|
||||
name = "README.md"
|
||||
extension = "md"
|
||||
description = "Project overview with tool reference table, install, and configuration"
|
||||
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 = "package.json"
|
||||
extension = "json"
|
||||
description = "Node.js project manifest — @mokoconsulting scoped, MCP SDK + Zod dependencies"
|
||||
requirement_status = "required"
|
||||
always_overwrite = false
|
||||
protected = true
|
||||
audience = "developer"
|
||||
},
|
||||
{
|
||||
name = "tsconfig.json"
|
||||
extension = "json"
|
||||
description = "TypeScript configuration — ES2022 target, Node16 module, strict mode"
|
||||
requirement_status = "required"
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
},
|
||||
{
|
||||
name = "config.example.json"
|
||||
extension = "json"
|
||||
description = "Example multi-connection configuration file"
|
||||
requirement_status = "required"
|
||||
always_overwrite = false
|
||||
protected = true
|
||||
audience = "general"
|
||||
},
|
||||
{
|
||||
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 = ".gitmessage"
|
||||
extension = "gitmessage"
|
||||
description = "Conventional commit message template"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
audience = "developer"
|
||||
},
|
||||
{
|
||||
name = "Makefile"
|
||||
description = "Build automation — install, build, dev, clean, setup, start targets"
|
||||
requirement_status = "required"
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
}
|
||||
]
|
||||
|
||||
directories = [
|
||||
{
|
||||
name = "src"
|
||||
path = "src"
|
||||
description = "TypeScript source code"
|
||||
requirement_status = "required"
|
||||
purpose = "Contains MCP server entry point, API client, config loader, and type definitions"
|
||||
files = [
|
||||
{
|
||||
name = "index.ts"
|
||||
extension = "ts"
|
||||
description = "MCP server entry point — registers all API tools with McpServer"
|
||||
requirement_status = "required"
|
||||
},
|
||||
{
|
||||
name = "client.ts"
|
||||
extension = "ts"
|
||||
description = "HTTP client wrapper for the target API (GET/POST/PUT/DELETE)"
|
||||
requirement_status = "required"
|
||||
},
|
||||
{
|
||||
name = "config.ts"
|
||||
extension = "ts"
|
||||
description = "Configuration loader — reads ~/.{project}.json with multi-connection support"
|
||||
requirement_status = "required"
|
||||
},
|
||||
{
|
||||
name = "types.ts"
|
||||
extension = "ts"
|
||||
description = "TypeScript interfaces for connection, config, and API response types"
|
||||
requirement_status = "required"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name = "scripts"
|
||||
path = "scripts"
|
||||
description = "Setup and utility scripts"
|
||||
requirement_status = "required"
|
||||
purpose = "Contains interactive setup wizard and repo-specific helpers"
|
||||
files = [
|
||||
{
|
||||
name = "setup.mjs"
|
||||
extension = "mjs"
|
||||
description = "Interactive setup wizard — prompts for API connection details and writes config"
|
||||
requirement_status = "required"
|
||||
always_overwrite = false
|
||||
protected = true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name = "docs"
|
||||
path = "docs"
|
||||
description = "Documentation directory"
|
||||
requirement_status = "required"
|
||||
purpose = "Contains project documentation"
|
||||
files = [
|
||||
{
|
||||
name = "index.md"
|
||||
extension = "md"
|
||||
description = "Documentation index"
|
||||
requirement_status = "suggested"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name = ".gitea"
|
||||
path = ".gitea"
|
||||
description = "Gitea-specific configuration"
|
||||
requirement_status = "required"
|
||||
purpose = "Contains Gitea Actions workflows and platform configuration"
|
||||
files = [
|
||||
{
|
||||
name = ".mokostandards"
|
||||
description = "MokoStandards platform declaration — must contain 'platform: mcp-server'"
|
||||
requirement_status = "required"
|
||||
always_overwrite = false
|
||||
}
|
||||
]
|
||||
subdirectories = [
|
||||
{
|
||||
name = "workflows"
|
||||
path = ".gitea/workflows"
|
||||
description = "Gitea Actions workflows"
|
||||
requirement_status = "required"
|
||||
files = [
|
||||
{
|
||||
name = "auto-release.yml"
|
||||
extension = "yml"
|
||||
description = "Auto-create release on push to main"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/auto-release.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 = "auto-assign.yml"
|
||||
extension = "yml"
|
||||
description = "Auto-assign issues and PRs"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/auto-assign.yml.template"
|
||||
},
|
||||
{
|
||||
name = "standards-compliance.yml"
|
||||
extension = "yml"
|
||||
description = "MokoStandards compliance validation"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/standards-compliance.yml.template"
|
||||
},
|
||||
{
|
||||
name = "codeql-analysis.yml"
|
||||
extension = "yml"
|
||||
description = "CodeQL security analysis"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/codeql-analysis.yml.template"
|
||||
},
|
||||
{
|
||||
name = "changelog-validation.yml"
|
||||
extension = "yml"
|
||||
description = "CHANGELOG validation on PR"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/changelog-validation.yml.template"
|
||||
},
|
||||
{
|
||||
name = "sync-version-on-merge.yml"
|
||||
extension = "yml"
|
||||
description = "Auto-bump patch version on merge"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/sync-version-on-merge.yml.template"
|
||||
},
|
||||
{
|
||||
name = "repository-cleanup.yml"
|
||||
extension = "yml"
|
||||
description = "Scheduled cleanup of stale branches and workflow runs"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/repository-cleanup.yml.template"
|
||||
},
|
||||
{
|
||||
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 = "Deployment to development server"
|
||||
requirement_status = "suggested"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/deploy-dev.yml.template"
|
||||
},
|
||||
{
|
||||
name = "deploy-demo.yml"
|
||||
extension = "yml"
|
||||
description = "Deployment to demo server on merge to main"
|
||||
requirement_status = "suggested"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/deploy-demo.yml.template"
|
||||
},
|
||||
{
|
||||
name = "copilot-agent.yml"
|
||||
extension = "yml"
|
||||
description = "Copilot agent workflow for automated code review"
|
||||
requirement_status = "optional"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/shared/copilot-agent.yml.template"
|
||||
},
|
||||
{
|
||||
name = "mcp-build-test.yml"
|
||||
extension = "yml"
|
||||
description = "MCP server build validation — TypeScript compile, dist verification, tool count"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/mcp/mcp-build-test.yml.template"
|
||||
},
|
||||
{
|
||||
name = "mcp-sdk-check.yml"
|
||||
extension = "yml"
|
||||
description = "Weekly check for MCP SDK and Zod updates — creates issue when new version available"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/mcp/mcp-sdk-check.yml.template"
|
||||
},
|
||||
{
|
||||
name = "mcp-tool-inventory.yml"
|
||||
extension = "yml"
|
||||
description = "Generate tool inventory report on push to main"
|
||||
requirement_status = "suggested"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/mcp/mcp-tool-inventory.yml.template"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name = "dist"
|
||||
path = "dist"
|
||||
description = "Compiled JavaScript output (generated)"
|
||||
requirement_status = "not-allowed"
|
||||
purpose = "Generated directory that should not be committed"
|
||||
},
|
||||
{
|
||||
name = "node_modules"
|
||||
path = "node_modules"
|
||||
description = "Node.js dependencies (generated)"
|
||||
requirement_status = "not-allowed"
|
||||
purpose = "Generated directory that should not be committed"
|
||||
}
|
||||
]
|
||||
|
||||
repository_requirements = {
|
||||
secrets = [
|
||||
{
|
||||
name = "GH_TOKEN"
|
||||
description = "Org-level Gitea PAT — configure in org Actions secrets"
|
||||
required = true
|
||||
scope = "organisation"
|
||||
used_in = "Gitea Actions workflows"
|
||||
}
|
||||
]
|
||||
|
||||
variables = [
|
||||
{
|
||||
name = "NODE_VERSION"
|
||||
description = "Node.js version for CI/CD"
|
||||
default_value = "20"
|
||||
required = false
|
||||
scope = "repository"
|
||||
}
|
||||
]
|
||||
|
||||
branch_protections = [
|
||||
{
|
||||
branch_pattern = "main"
|
||||
require_pull_request = true
|
||||
required_approvals = 1
|
||||
require_code_owner_review = false
|
||||
dismiss_stale_reviews = true
|
||||
require_status_checks = true
|
||||
required_status_checks = ["ci"]
|
||||
enforce_admins = false
|
||||
restrict_pushes = true
|
||||
}
|
||||
]
|
||||
|
||||
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"
|
||||
},
|
||||
{
|
||||
name = "new-tool"
|
||||
color = "5319e7"
|
||||
description = "New MCP tool/endpoint to add"
|
||||
},
|
||||
{
|
||||
name = "api-change"
|
||||
color = "fbca04"
|
||||
description = "Upstream API changed — tool needs update"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,12 @@
|
||||
* Dolibarr Platform Structure Definition
|
||||
* Standard repository structure for the full Dolibarr ERP/CRM installation
|
||||
*
|
||||
* This is distinct from crm-module — it defines the ENTIRE Dolibarr platform
|
||||
* This is distinct from dolibarr — it defines the ENTIRE Dolibarr platform
|
||||
* (htdocs/, not src/). It does NOT have a module descriptor, numero, or
|
||||
* publish-to-mokodolimods workflow.
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
@@ -17,11 +16,11 @@ locals {
|
||||
metadata = {
|
||||
name = "Dolibarr Platform"
|
||||
description = "Full Dolibarr ERP/CRM installation — htdocs/ root, not a module"
|
||||
repository_type = "crm-platform"
|
||||
repository_type = "platform"
|
||||
platform = "dolibarr"
|
||||
last_updated = "2026-03-31T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
@@ -84,12 +83,22 @@ locals {
|
||||
template = "templates/configs/ftp_ignore"
|
||||
},
|
||||
{
|
||||
name = ".mokostandards"
|
||||
extension = ""
|
||||
description = "MokoStandards platform identifier"
|
||||
name = ".gitea/.mokostandards"
|
||||
extension = "xml"
|
||||
description = "MokoStandards XML manifest — generated programmatically by RepositorySynchronizer::migrateMokoStandards()"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
template = "templates/configs/mokostandards.yml.template"
|
||||
always_overwrite = false
|
||||
template = "managed-by-sync"
|
||||
source_type = "programmatic"
|
||||
},
|
||||
{
|
||||
name = "renovate.json"
|
||||
extension = "json"
|
||||
description = "Renovate dependency management configuration"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
template = "templates/configs/renovate.json"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -218,6 +227,22 @@ locals {
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
template = "templates/workflows/dolibarr/repo_health.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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4,7 +4,6 @@
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
@@ -17,7 +16,7 @@ locals {
|
||||
platform = "standards"
|
||||
last_updated = "2026-03-03T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
@@ -199,12 +198,23 @@ locals {
|
||||
audience = "developer"
|
||||
},
|
||||
{
|
||||
name = ".mokostandards"
|
||||
extension = ""
|
||||
description = "MokoStandards sync tracking file — records last sync date, version, and compliance status"
|
||||
name = ".gitea/.mokostandards"
|
||||
extension = "xml"
|
||||
description = "MokoStandards XML manifest — generated programmatically by RepositorySynchronizer::migrateMokoStandards()"
|
||||
requirement_status = "required"
|
||||
always_overwrite = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
template = "managed-by-sync"
|
||||
source_type = "programmatic"
|
||||
},
|
||||
{
|
||||
name = "renovate.json"
|
||||
extension = "json"
|
||||
description = "Renovate dependency management configuration"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
audience = "developer"
|
||||
template = "templates/configs/renovate.json"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -495,6 +505,22 @@ locals {
|
||||
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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -666,20 +692,52 @@ locals {
|
||||
}
|
||||
]
|
||||
|
||||
branch_protections = {
|
||||
main = {
|
||||
required_status_checks = {
|
||||
strict = true
|
||||
contexts = ["standards-compliance", "code-quality"]
|
||||
}
|
||||
enforce_admins = false
|
||||
required_pull_request_reviews = {
|
||||
dismiss_stale_reviews = true
|
||||
require_code_owner_reviews = true
|
||||
required_approving_review_count = 1
|
||||
}
|
||||
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
|
||||
@@ -1,3 +1,14 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Index
|
||||
INGROUP: MokoStandards.Definitions
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /definitions/index.md
|
||||
BRIEF: Definitions directory index
|
||||
-->
|
||||
|
||||
# Docs Index: /api/definitions
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -83,7 +83,6 @@ locals {
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
@@ -96,7 +95,7 @@ locals {
|
||||
platform = "multi-platform"
|
||||
last_updated = "2026-01-16T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@ locals {
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
@@ -111,7 +110,7 @@ locals {
|
||||
platform = "multi-platform"
|
||||
last_updated = "2026-01-16T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ locals {
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Version: 04.05.00
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
@@ -112,7 +111,7 @@ locals {
|
||||
platform = "multi-platform"
|
||||
last_updated = "2026-01-16T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "04.05.00"
|
||||
version = "05.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user