Compare commits
659 Commits
development
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| beed1e628a | |||
| d7fdd99f68 | |||
| a93794f1ba | |||
| fe3644204a | |||
| 34ef05bd6e | |||
| e177971462 | |||
| 51e599acef | |||
| c73675234b | |||
| 16ff7e611e | |||
| 030f057ab4 | |||
| fec5464c17 | |||
| 2723fbf0e7 | |||
| e9dcd48e44 | |||
| 1bd170c77f | |||
| 1e18d6bcb8 | |||
| 35befccf06 | |||
| d012bb900b | |||
| 2ab332161d | |||
| a9acb2d27c | |||
| e609a4e205 | |||
| dbf4cdef9a | |||
| 24d5238b64 | |||
| 4ccefec2dc | |||
| 5a8b18ea8d | |||
| 9dbf790e1a | |||
| a07d93b6fc | |||
| 415e58d06c | |||
| d8ec7b5ba0 | |||
| e882425f04 | |||
| 3171fb3ef0 | |||
| 6cd46f0b7f | |||
| 48ae7c1e88 | |||
| 63a2640254 | |||
| 6ec2202c6e | |||
| 8f7cce051b | |||
| f426f21f2e | |||
| 2ee5a55ec5 | |||
| a04040533c | |||
| d5541abf22 | |||
| 8ae829ad89 | |||
| 5815ad040f | |||
| 96b6db73a9 | |||
| 8eb3e310cf | |||
| eca475c6e3 | |||
| 92822303ef | |||
| 9649fb55cf | |||
| 8f39017b59 | |||
| bd18642045 | |||
| 820e968e1a | |||
| a5cd566dea | |||
| b5599579a7 | |||
| 61a232dfc6 | |||
| a45bf42335 | |||
| 77a1ae3977 | |||
| fb5461b661 | |||
| e15421699e | |||
| 48d574e225 | |||
| 1dba0c37b9 | |||
| 07ea171af9 | |||
| 420b4f5f3c | |||
| f8c28f055b | |||
| a7df4d49b9 | |||
| 320b2c57be | |||
| d323ca52af | |||
| c5e4b41100 | |||
| 335fcd0382 | |||
| c1c820bb5c | |||
| f441a8a51f | |||
| 005eb5cf39 | |||
| 21acb19fed | |||
| 1fe4f83e73 | |||
| 7e5c322792 | |||
| b010677d75 | |||
| 9275e581c2 | |||
| 3f3b1f79a0 | |||
| 83842c50ad | |||
| fbedd5966c | |||
| eca2c13018 | |||
| 48d000107d | |||
| 7ceb9528cc | |||
| 5fabaec477 | |||
| e40b799101 | |||
| 7e9784e723 | |||
| 209dee14fd | |||
| 81351f45fd | |||
| fd451b4b73 | |||
| d0dbd1dceb | |||
| 3e2e291819 | |||
| 5975ea38d8 | |||
| 8ad548f4a3 | |||
| cbb4d73df5 | |||
| 47cb47ebdb | |||
| 22b0f8af7e | |||
| 08ca1429ae | |||
| e8da1a30ff | |||
| fb754b1a07 | |||
| 9a2c164207 | |||
| 78c1329a83 | |||
| 05f43ed88f | |||
| 05e4f39e7d | |||
| 3dcb3b6d3a | |||
| db4e6f5c6b | |||
| aa7fc45a67 | |||
| 03fe66238f | |||
| a5ae616a94 | |||
| ff7924de7d | |||
| 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
|
*.coverage
|
||||||
hypothesis/
|
hypothesis/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Local wiki clone (not version controlled)
|
||||||
|
# ============================================================
|
||||||
|
wiki/
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Dolibarr (base + runtime)
|
# Dolibarr (base + runtime)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -682,6 +687,7 @@ modulebuilder.txt
|
|||||||
!/bin/moko
|
!/bin/moko
|
||||||
/cache/*
|
/cache/*
|
||||||
/cli/*
|
/cli/*
|
||||||
|
!/cli/*.php
|
||||||
/components/com_ajax/*
|
/components/com_ajax/*
|
||||||
/components/com_banners/*
|
/components/com_banners/*
|
||||||
/components/com_config/*
|
/components/com_config/*
|
||||||
@@ -1062,3 +1068,5 @@ terraform.rc
|
|||||||
# but can be ignored if you want flexibility across different platforms
|
# but can be ignored if you want flexibility across different platforms
|
||||||
# !.terraform.lock.hcl
|
# !.terraform.lock.hcl
|
||||||
logs/validation/*.md
|
logs/validation/*.md
|
||||||
|
profile.ps1
|
||||||
|
.mcp.json
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ contact_links:
|
|||||||
- name: 💬 Ask a Question
|
- name: 💬 Ask a Question
|
||||||
url: https://mokoconsulting.tech/
|
url: https://mokoconsulting.tech/
|
||||||
about: Get help or ask questions through our website
|
about: Get help or ask questions through our website
|
||||||
- name: 📚 MokoStandards Documentation
|
- name: 📚 moko-platform Documentation
|
||||||
url: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards
|
url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
about: View our coding standards and best practices
|
about: View our coding standards and best practices
|
||||||
- name: 🔒 Report a Security Vulnerability
|
- name: 🔒 Report a Security Vulnerability
|
||||||
url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new
|
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.
|
Add any other context, mockups, or screenshots about the feature request here.
|
||||||
|
|
||||||
## Relevant Standards
|
## Relevant Standards
|
||||||
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
|
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
|
||||||
- [ ] Accessibility (WCAG 2.1 AA)
|
- [ ] Accessibility (WCAG 2.1 AA)
|
||||||
- [ ] Localization (en_US/en_GB)
|
- [ ] Localization (en_US/en_GB)
|
||||||
- [ ] Security best practices
|
- [ ] Security best practices
|
||||||
@@ -3,7 +3,7 @@ name: Question
|
|||||||
about: Ask a question about usage, features, or best practices
|
about: Ask a question about usage, features, or best practices
|
||||||
title: '[QUESTION] '
|
title: '[QUESTION] '
|
||||||
labels: ['question']
|
labels: ['question']
|
||||||
assignees: ['jmiller-moko']
|
assignees: ['jmiller']
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ Use this template only for:
|
|||||||
<!-- Describe how this could be addressed -->
|
<!-- Describe how this could be addressed -->
|
||||||
|
|
||||||
## Standards Reference
|
## Standards Reference
|
||||||
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
|
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
|
||||||
- [ ] SPDX license identifiers
|
- [ ] SPDX license identifiers
|
||||||
- [ ] Secret management
|
- [ ] Secret management
|
||||||
- [ ] Dependency security
|
- [ ] Dependency security
|
||||||
@@ -3,7 +3,7 @@ name: Version Bump
|
|||||||
about: Request or track a version change
|
about: Request or track a version change
|
||||||
title: '[VERSION] '
|
title: '[VERSION] '
|
||||||
labels: 'version, type: version'
|
labels: 'version, type: version'
|
||||||
assignees: 'jmiller-moko'
|
assignees: 'jmiller'
|
||||||
---
|
---
|
||||||
|
|
||||||
## Version Change
|
## 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,29 @@
|
|||||||
|
<?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"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="https://standards.mokoconsulting.tech/moko-platform/1.0 https://git.mokoconsulting.tech/MokoConsulting/moko-platform/raw/branch/main/templates/schemas/manifest-schema.xsd"
|
||||||
|
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>
|
||||||
|
<version>09.02.05</version>
|
||||||
|
<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,84 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: moko-platform.Release
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||||
|
# VERSION: 09.02.00
|
||||||
|
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||||
|
|
||||||
|
name: "Universal: Auto Version Bump"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
name: Version Bump
|
||||||
|
runs-on: release
|
||||||
|
if: >-
|
||||||
|
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||||
|
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||||
|
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GA_TOKEN }}
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup moko-platform tools
|
||||||
|
run: |
|
||||||
|
if ! command -v composer &> /dev/null; then
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
if [ -d "/opt/moko-platform/cli" ]; then
|
||||||
|
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
git clone --depth 1 --branch main --quiet \
|
||||||
|
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||||
|
/tmp/moko-platform-api
|
||||||
|
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||||
|
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Bump version
|
||||||
|
run: |
|
||||||
|
BUMP=$(php ${MOKO_CLI}/version_bump.php --path . 2>&1) || true
|
||||||
|
echo "$BUMP"
|
||||||
|
|
||||||
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null) || true
|
||||||
|
[ -z "$VERSION" ] && { echo "No version found — skipping"; exit 0; }
|
||||||
|
|
||||||
|
# Propagate to platform manifests
|
||||||
|
php ${MOKO_CLI}/version_set_platform.php \
|
||||||
|
--path . --version "$VERSION" --branch dev 2>/dev/null || true
|
||||||
|
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||||
|
|
||||||
|
# Commit if anything changed
|
||||||
|
if git diff --quiet && git diff --cached --quiet; then
|
||||||
|
echo "No version changes to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 commit -m "chore(version): patch bump to ${VERSION} [skip ci]" \
|
||||||
|
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||||
|
git push origin dev
|
||||||
|
echo "Bumped to ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
# 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: [opened, closed]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
action:
|
||||||
|
description: 'Action to perform'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: release
|
||||||
|
options:
|
||||||
|
- release
|
||||||
|
- promote-rc
|
||||||
|
|
||||||
|
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:
|
||||||
|
# ── Draft PR → Promote highest pre-release to RC ─────────────────────────────
|
||||||
|
promote-rc:
|
||||||
|
name: Promote Pre-Release to RC
|
||||||
|
runs-on: release
|
||||||
|
if: >-
|
||||||
|
(github.event.action == 'opened' && github.event.pull_request.draft == true) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GA_TOKEN }}
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup moko-platform tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
|
run: |
|
||||||
|
if ! command -v composer &> /dev/null; then
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Promote to release-candidate
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php /tmp/moko-platform-api/cli/release_promote.php \
|
||||||
|
--from auto --to release-candidate \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" \
|
||||||
|
--api-base "${API_BASE}" \
|
||||||
|
--branch "${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||||
|
|
||||||
|
- name: Cascade lesser channels
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php /tmp/moko-platform-api/cli/release_cascade.php \
|
||||||
|
--stability release-candidate \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" \
|
||||||
|
--api-base "${API_BASE}"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Draft PR opened — promoted highest pre-release to RC" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||||
|
release:
|
||||||
|
name: Build & Release Pipeline
|
||||||
|
runs-on: release
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.merged == true ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
# -- CHECK FOR RC PROMOTION ------------------------------------------------
|
||||||
|
- name: "Check for RC release"
|
||||||
|
id: rc
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
RC_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||||
|
"${API_BASE}/releases/tags/release-candidate" 2>/dev/null || echo "{}")
|
||||||
|
RC_ID=$(echo "$RC_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$RC_ID" ] && [ "$RC_ID" != "None" ] && [ "$RC_ID" != "" ]; then
|
||||||
|
echo "promote=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_id=${RC_ID}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::RC release found (id: ${RC_ID}) — will promote to stable"
|
||||||
|
else
|
||||||
|
echo "promote=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::No RC release — full build pipeline"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: "Step 1b: Minor bump version"
|
||||||
|
id: bump
|
||||||
|
if: >-
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
steps.rc.outputs.promote != 'true'
|
||||||
|
run: |
|
||||||
|
MOKO_API="/tmp/moko-platform-api/cli"
|
||||||
|
php ${MOKO_API}/version_bump.php --path . --minor 2>&1 || true
|
||||||
|
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 }}"
|
||||||
|
php /tmp/moko-platform-api/cli/release_validate.php \
|
||||||
|
--path . --version "$VERSION" --output-summary --github-output || true
|
||||||
|
|
||||||
|
# -- 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
|
||||||
|
|
||||||
|
# Step 5 (updates.xml) moved after Step 8 to include SHA-256 checksum
|
||||||
|
|
||||||
|
- name: "Step 4b: Promote and prune CHANGELOG"
|
||||||
|
if: >-
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
steps.check.outputs.already_released != 'true'
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
MOKO_API="/tmp/moko-platform-api/cli"
|
||||||
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
|
php ${MOKO_API}/changelog_promote.php --path . --version "$VERSION" 2>&1 || true
|
||||||
|
php ${MOKO_API}/changelog_prune.php --path . --keep 5 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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 7a: Promote RC to stable (skip build) ----------------------------
|
||||||
|
- name: "Step 7a: Promote RC to stable"
|
||||||
|
if: >-
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
steps.rc.outputs.promote == 'true'
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php /tmp/moko-platform-api/cli/release_promote.php \
|
||||||
|
--from release-candidate --to stable \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" \
|
||||||
|
--api-base "${API_BASE}" \
|
||||||
|
--path . --branch main
|
||||||
|
echo "Promoted RC → stable (${VERSION})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# -- STEP 7b: Create or update Gitea Release (full build path) -------------
|
||||||
|
- name: "Step 7b: Gitea Release"
|
||||||
|
if: >-
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
steps.rc.outputs.promote != 'true'
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php /tmp/moko-platform-api/cli/release_create.php \
|
||||||
|
--path . --version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
|
--repo "${GITEA_REPO}" --branch main
|
||||||
|
echo "Release created: ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# -- STEP 8: Build packages and upload to release ----------------------------
|
||||||
|
- name: "Step 8: Build package and upload"
|
||||||
|
id: package
|
||||||
|
if: >-
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
steps.rc.outputs.promote != 'true'
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php /tmp/moko-platform-api/cli/release_package.php \
|
||||||
|
--path . --version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
|
--repo "${GITEA_REPO}" --output /tmp || true
|
||||||
|
|
||||||
|
# -- STEP 5: Write update stream (after build so SHA-256 is available) -----
|
||||||
|
- name: "Step 5: Write update stream"
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
SHA_FLAG=""
|
||||||
|
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||||
|
|
||||||
|
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}" \
|
||||||
|
${SHA_FLAG} --github-output
|
||||||
|
|
||||||
|
# Commit updates.xml if changed
|
||||||
|
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 remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
git add updates.xml
|
||||||
|
git commit -m "chore: update stable channel ${VERSION} [skip ci]" \
|
||||||
|
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||||
|
git push origin HEAD 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- STEP 8b: Update release description with changelog ----------------------
|
||||||
|
- name: "Step 8b: Update release body"
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
|
php /tmp/moko-platform-api/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>&1 || true
|
||||||
|
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' &&
|
||||||
|
secrets.GH_TOKEN != ''
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
|
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||||
|
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
|
--gh-token "${{ secrets.GH_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||||
|
--branch main 2>&1 || true
|
||||||
|
echo "GitHub mirror updated" >> $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: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php /tmp/moko-platform-api/cli/release_cascade.php \
|
||||||
|
--stability stable \
|
||||||
|
--version "${VERSION}" \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" \
|
||||||
|
--api-base "${API_BASE}" 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
|
||||||
|
|
||||||
|
- name: "Step 12: Create version 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 }}"
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
BRANCH_NAME="version/${VERSION}"
|
||||||
|
MAIN_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
# Delete old version branch if it exists (same version re-release)
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||||
|
|
||||||
|
# Create version/XX.YY.ZZ from main
|
||||||
|
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||||
|
|
||||||
|
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||||
|
- name: "Post-release: Reset dev version"
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" --api-base "${API_BASE}" \
|
||||||
|
--branch dev --path . 2>&1 || true
|
||||||
|
|
||||||
|
# -- Summary --------------------------------------------------------------
|
||||||
|
- name: Pipeline Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||||
|
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||||
|
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||||
|
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: MokoStandards.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Delete feature branches after PR merge
|
||||||
|
|
||||||
|
name: "Branch Cleanup"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
name: Delete merged branch
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.merged == true &&
|
||||||
|
github.event.pull_request.head.ref != 'dev' &&
|
||||||
|
github.event.pull_request.head.ref != 'main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Delete source branch
|
||||||
|
run: |
|
||||||
|
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||||
|
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||||
|
ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BRANCH}', safe=''))")
|
||||||
|
|
||||||
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
-H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||||
|
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$STATUS" = "204" ]; then
|
||||||
|
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
elif [ "$STATUS" = "404" ]; then
|
||||||
|
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
|
||||||
|
fi
|
||||||
@@ -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)"
|
||||||
@@ -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,223 @@
|
|||||||
|
# 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:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
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 || 'development' }})"
|
||||||
|
runs-on: release
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GA_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup moko-platform tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
|
run: |
|
||||||
|
if ! command -v composer &> /dev/null; then
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
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
|
||||||
|
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Detect platform
|
||||||
|
id: platform
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||||
|
|
||||||
|
- name: Resolve metadata and bump version
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
STABILITY="${{ inputs.stability || 'development' }}"
|
||||||
|
|
||||||
|
# Map stability to Gitea release tag
|
||||||
|
case "$STABILITY" in
|
||||||
|
development) TAG="development" ;;
|
||||||
|
alpha) TAG="alpha" ;;
|
||||||
|
beta) TAG="beta" ;;
|
||||||
|
release-candidate) TAG="release-candidate" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Read current version (includes suffix from manifest, e.g. 01.02.14-dev)
|
||||||
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||||
|
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||||
|
|
||||||
|
php ${MOKO_CLI}/version_set_platform.php \
|
||||||
|
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Verify version consistency across all files
|
||||||
|
php ${MOKO_CLI}/version_check.php --path . --fix 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 via manifest_element.php
|
||||||
|
php ${MOKO_CLI}/manifest_element.php \
|
||||||
|
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||||
|
--repo "${GITEA_REPO}" --github-output
|
||||||
|
|
||||||
|
# Read back element outputs
|
||||||
|
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||||
|
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||||
|
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||||
|
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php ${MOKO_CLI}/release_create.php \
|
||||||
|
--path . --version "$VERSION" --tag "$TAG" \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
|
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||||
|
|
||||||
|
- name: Build package and upload
|
||||||
|
id: package
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php ${MOKO_CLI}/release_package.php \
|
||||||
|
--path . --version "$VERSION" --tag "$TAG" \
|
||||||
|
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
|
--repo "${GITEA_REPO}" --output /tmp || true
|
||||||
|
|
||||||
|
- name: Update updates.xml
|
||||||
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||||
|
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||||
|
|
||||||
|
if [ ! -f "updates.xml" ]; then
|
||||||
|
echo "No updates.xml -- skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHA_FLAG=""
|
||||||
|
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||||
|
|
||||||
|
php ${MOKO_CLI}/updates_xml_build.php \
|
||||||
|
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||||
|
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||||
|
${SHA_FLAG}
|
||||||
|
|
||||||
|
# Commit and push
|
||||||
|
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||||
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
|
git config --local user.name "gitea-actions[bot]"
|
||||||
|
git add updates.xml
|
||||||
|
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||||
|
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: "Sync updates.xml to all branches"
|
||||||
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
|
run: |
|
||||||
|
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||||
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
|
git config --local user.name "gitea-actions[bot]"
|
||||||
|
|
||||||
|
for BRANCH in main dev; do
|
||||||
|
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
||||||
|
echo "Syncing updates.xml -> ${BRANCH}"
|
||||||
|
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
||||||
|
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
|
||||||
|
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
||||||
|
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||||
|
git add updates.xml
|
||||||
|
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
||||||
|
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
||||||
|
fi
|
||||||
|
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
- 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.package.outputs.sha256_zip }}"
|
||||||
|
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
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.Validation
|
# INGROUP: moko-platform.Validation
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
# PATH: /.github/workflows/repo_health.yml
|
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||||
# VERSION: 04.06.00
|
# VERSION: 04.06.00
|
||||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
# 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
|
name: "Generic: Repo Health"
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: repo-health-${{ github.repository }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -50,13 +45,11 @@ env:
|
|||||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||||
|
|
||||||
# Scripts governance policy
|
# Scripts governance policy
|
||||||
# Note: directories listed without a trailing slash.
|
|
||||||
SCRIPTS_REQUIRED_DIRS:
|
SCRIPTS_REQUIRED_DIRS:
|
||||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||||
|
|
||||||
# Repo health policy
|
# 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,.mokogitea/workflows/
|
||||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.github/workflows/
|
|
||||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||||
REPO_DISALLOWED_DIRS:
|
REPO_DISALLOWED_DIRS:
|
||||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||||
@@ -64,10 +57,10 @@ env:
|
|||||||
# Extended checks toggles
|
# Extended checks toggles
|
||||||
EXTENDED_CHECKS: "true"
|
EXTENDED_CHECKS: "true"
|
||||||
|
|
||||||
# File / directory variables (moved to top-level env)
|
# File / directory variables
|
||||||
DOCS_INDEX: docs/docs-index.md
|
DOCS_INDEX: docs/docs-index.md
|
||||||
SCRIPT_DIR: scripts
|
SCRIPT_DIR: scripts
|
||||||
WORKFLOWS_DIR: .github/workflows
|
WORKFLOWS_DIR: .mokogitea/workflows
|
||||||
SHELLCHECK_PATTERN: '*.sh'
|
SHELLCHECK_PATTERN: '*.sh'
|
||||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
@@ -99,7 +92,7 @@ jobs:
|
|||||||
|
|
||||||
# Hardcoded authorized users — always allowed
|
# Hardcoded authorized users — always allowed
|
||||||
case "$ACTOR" in
|
case "$ACTOR" in
|
||||||
jmiller-moko|github-actions\[bot\])
|
jmiller|gitea-actions[bot])
|
||||||
ALLOWED=true
|
ALLOWED=true
|
||||||
PERMISSION=admin
|
PERMISSION=admin
|
||||||
METHOD="hardcoded allowlist"
|
METHOD="hardcoded allowlist"
|
||||||
@@ -121,7 +114,7 @@ jobs:
|
|||||||
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
|
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "## 🔐 Access Authorization"
|
echo "## Access Authorization"
|
||||||
echo ""
|
echo ""
|
||||||
echo "| Field | Value |"
|
echo "| Field | Value |"
|
||||||
echo "|-------|-------|"
|
echo "|-------|-------|"
|
||||||
@@ -132,9 +125,9 @@ jobs:
|
|||||||
echo "| **Authorized** | ${ALLOWED} |"
|
echo "| **Authorized** | ${ALLOWED} |"
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$ALLOWED" = "true" ]; then
|
if [ "$ALLOWED" = "true" ]; then
|
||||||
echo "✅ ${ACTOR} authorized (${METHOD})"
|
echo "${ACTOR} authorized (${METHOD})"
|
||||||
else
|
else
|
||||||
echo "❌ ${ACTOR} is NOT authorized. Requires admin or maintain role."
|
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
|
||||||
fi
|
fi
|
||||||
} >> "${GITHUB_STEP_SUMMARY}"
|
} >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
|
||||||
@@ -291,7 +284,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
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}"
|
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
|
||||||
|
|
||||||
missing_dirs=()
|
missing_dirs=()
|
||||||
@@ -395,23 +388,27 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
if [ -d "src" ]; then
|
||||||
SOURCE_DIR="src"
|
SOURCE_DIR="src"
|
||||||
elif [ -d "htdocs" ]; then
|
elif [ -d "htdocs" ]; then
|
||||||
SOURCE_DIR="htdocs"
|
SOURCE_DIR="htdocs"
|
||||||
|
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||||
|
# Platform/tooling repos don't need src/
|
||||||
|
SOURCE_DIR=""
|
||||||
else
|
else
|
||||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||||
fi
|
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
|
for item in "${required_artifacts[@]}"; do
|
||||||
if printf '%s' "${item}" | grep -q '/$'; then
|
if printf '%s' "${item}" | grep -q '/$'; then
|
||||||
d="${item%/}"
|
d="${item%/}"
|
||||||
@@ -421,7 +418,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Optional entries: handle files and directories (trailing slash indicates dir)
|
|
||||||
for f in "${optional_files[@]}"; do
|
for f in "${optional_files[@]}"; do
|
||||||
if printf '%s' "${f}" | grep -q '/$'; then
|
if printf '%s' "${f}" | grep -q '/$'; then
|
||||||
d="${f%/}"
|
d="${f%/}"
|
||||||
@@ -445,8 +441,6 @@ jobs:
|
|||||||
dev_paths=()
|
dev_paths=()
|
||||||
dev_branches=()
|
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
|
while IFS= read -r b; do
|
||||||
name="${b#origin/}"
|
name="${b#origin/}"
|
||||||
if [ "${name}" = 'dev' ]; then
|
if [ "${name}" = 'dev' ]; then
|
||||||
@@ -456,14 +450,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
||||||
|
|
||||||
# If there are no dev/* branches, fail the guardrail.
|
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
|
||||||
if [ "${#dev_paths[@]}" -eq 0 ]; then
|
missing_required+=("dev or dev/* branch")
|
||||||
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>)")
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
content_warnings=()
|
content_warnings=()
|
||||||
@@ -489,26 +477,7 @@ jobs:
|
|||||||
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
|
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
|
||||||
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
|
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
|
||||||
|
|
||||||
report_json="$(python3 - <<'PY'
|
report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
|
||||||
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
|
|
||||||
)"
|
|
||||||
|
|
||||||
{
|
{
|
||||||
printf '%s\n' '### Repository health'
|
printf '%s\n' '### Repository health'
|
||||||
@@ -553,54 +522,47 @@ jobs:
|
|||||||
} >> "${GITHUB_STEP_SUMMARY}"
|
} >> "${GITHUB_STEP_SUMMARY}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Joomla-specific checks ───────────────────────────────────────
|
# -- Joomla-specific checks --
|
||||||
joomla_findings=()
|
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)"
|
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
|
||||||
if [ -z "${MANIFEST}" ]; then
|
if [ -z "${MANIFEST}" ]; then
|
||||||
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
|
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
|
||||||
else
|
else
|
||||||
# Check <version> tag exists
|
|
||||||
if ! grep -qP '<version>' "${MANIFEST}"; then
|
if ! grep -qP '<version>' "${MANIFEST}"; then
|
||||||
joomla_findings+=("XML manifest: <version> tag missing")
|
joomla_findings+=("XML manifest: <version> tag missing")
|
||||||
fi
|
fi
|
||||||
# Check extension type attribute
|
|
||||||
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
|
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
|
||||||
joomla_findings+=("XML manifest: type attribute missing or invalid")
|
joomla_findings+=("XML manifest: type attribute missing or invalid")
|
||||||
fi
|
fi
|
||||||
# Check <name> tag
|
|
||||||
if ! grep -qP '<name>' "${MANIFEST}"; then
|
if ! grep -qP '<name>' "${MANIFEST}"; then
|
||||||
joomla_findings+=("XML manifest: <name> tag missing")
|
joomla_findings+=("XML manifest: <name> tag missing")
|
||||||
fi
|
fi
|
||||||
# Check <author> tag
|
|
||||||
if ! grep -qP '<author>' "${MANIFEST}"; then
|
if ! grep -qP '<author>' "${MANIFEST}"; then
|
||||||
joomla_findings+=("XML manifest: <author> tag missing")
|
joomla_findings+=("XML manifest: <author> tag missing")
|
||||||
fi
|
fi
|
||||||
# Check <namespace> for Joomla 5+
|
|
||||||
if ! grep -qP '<namespace' "${MANIFEST}"; then
|
if ! grep -qP '<namespace' "${MANIFEST}"; then
|
||||||
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
|
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Language files: check for at least one .ini file
|
|
||||||
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
|
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
|
||||||
if [ "${INI_COUNT}" -eq 0 ]; then
|
if [ "${INI_COUNT}" -eq 0 ]; then
|
||||||
joomla_findings+=("No .ini language files found")
|
joomla_findings+=("No .ini language files found")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# updates.xml must exist in root (Joomla update server)
|
|
||||||
if [ ! -f 'updates.xml' ]; then
|
if [ ! -f 'updates.xml' ]; then
|
||||||
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
|
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# index.html files for directory listing protection
|
if [ -n "${SOURCE_DIR}" ]; then
|
||||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||||
for dir in "${INDEX_DIRS[@]}"; do
|
for dir in "${INDEX_DIRS[@]}"; do
|
||||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "${#joomla_findings[@]}" -gt 0 ]; then
|
if [ "${#joomla_findings[@]}" -gt 0 ]; then
|
||||||
{
|
{
|
||||||
@@ -624,14 +586,12 @@ jobs:
|
|||||||
extended_findings=()
|
extended_findings=()
|
||||||
|
|
||||||
if [ "${extended_enabled}" = 'true' ]; then
|
if [ "${extended_enabled}" = 'true' ]; then
|
||||||
# CODEOWNERS presence
|
|
||||||
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
|
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
|
||||||
:
|
:
|
||||||
else
|
else
|
||||||
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
|
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
|
||||||
fi
|
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
|
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)"
|
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
|
if [ -n "${bad_refs}" ]; then
|
||||||
@@ -647,51 +607,35 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Docs index link integrity (docs/docs-index.md)
|
|
||||||
if [ -f "${DOCS_INDEX}" ]; then
|
if [ -f "${DOCS_INDEX}" ]; then
|
||||||
missing_links="$(python3 - <<'PY'
|
missing_links=""
|
||||||
import os
|
while IFS= read -r docline; do
|
||||||
import re
|
for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
|
||||||
|
case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
|
||||||
idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
|
linkpath="${link%%#*}"
|
||||||
base = os.getcwd()
|
linkpath="${linkpath%%\?*}"
|
||||||
|
[ -z "$linkpath" ] && continue
|
||||||
bad = []
|
if [ "${linkpath:0:1}" = "/" ]; then
|
||||||
pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
|
testpath="${linkpath#/}"
|
||||||
|
else
|
||||||
with open(idx, 'r', encoding='utf-8') as f:
|
testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
|
||||||
for line in f:
|
fi
|
||||||
for m in pat.findall(line):
|
[ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
|
||||||
link = m.strip()
|
done
|
||||||
if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
|
done < "${DOCS_INDEX}"
|
||||||
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
|
|
||||||
)"
|
|
||||||
if [ -n "${missing_links}" ]; then
|
if [ -n "${missing_links}" ]; then
|
||||||
extended_findings+=("docs/docs-index.md contains broken relative links")
|
extended_findings+=("docs/docs-index.md contains broken relative links")
|
||||||
{
|
{
|
||||||
printf '%s\n' '### Docs index link integrity'
|
printf '%s\n' '### Docs index link integrity'
|
||||||
printf '%s\n' 'Broken relative links:'
|
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'
|
printf '\n'
|
||||||
} >> "${GITHUB_STEP_SUMMARY}"
|
} >> "${GITHUB_STEP_SUMMARY}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ShellCheck advisory
|
|
||||||
if [ -d "${SCRIPT_DIR}" ]; then
|
if [ -d "${SCRIPT_DIR}" ]; then
|
||||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
@@ -720,7 +664,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# SPDX header advisory for common source types
|
|
||||||
spdx_missing=()
|
spdx_missing=()
|
||||||
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
|
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
|
||||||
spdx_args=()
|
spdx_args=()
|
||||||
@@ -743,9 +686,8 @@ jobs:
|
|||||||
} >> "${GITHUB_STEP_SUMMARY}"
|
} >> "${GITHUB_STEP_SUMMARY}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Git hygiene advisory: branches older than 180 days (remote)
|
|
||||||
stale_cutoff_days=180
|
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
|
if [ -n "${stale_branches}" ]; then
|
||||||
extended_findings+=("Stale remote branches detected (advisory)")
|
extended_findings+=("Stale remote branches detected (advisory)")
|
||||||
{
|
{
|
||||||
@@ -787,3 +729,41 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
|
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 }}
|
||||||
|
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: MokoStandards.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /templates/workflows/update-server.yml
|
||||||
|
# VERSION: 04.07.00
|
||||||
|
# BRIEF: Update server XML feed with stable/rc/beta/alpha/dev entries (universal)
|
||||||
|
#
|
||||||
|
# Writes updates.xml with multiple <update> entries:
|
||||||
|
# - <tag>stable</tag> on push to main (from auto-release)
|
||||||
|
# - <tag>rc</tag> on push to rc/**
|
||||||
|
# - <tag>development</tag> on push to dev or dev/**
|
||||||
|
#
|
||||||
|
# Joomla filters by user's "Minimum Stability" setting.
|
||||||
|
|
||||||
|
name: "Update Server"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'dev'
|
||||||
|
- 'dev/**'
|
||||||
|
- 'alpha/**'
|
||||||
|
- 'beta/**'
|
||||||
|
- 'rc/**'
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'htdocs/**'
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches:
|
||||||
|
- 'dev'
|
||||||
|
- 'dev/**'
|
||||||
|
- 'alpha/**'
|
||||||
|
- 'beta/**'
|
||||||
|
- 'rc/**'
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'htdocs/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
stability:
|
||||||
|
description: 'Stability tag'
|
||||||
|
required: true
|
||||||
|
default: 'development'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- development
|
||||||
|
- alpha
|
||||||
|
- beta
|
||||||
|
- rc
|
||||||
|
- stable
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-xml:
|
||||||
|
name: Update updates.xml
|
||||||
|
runs-on: release
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.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: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
|
||||||
|
run: |
|
||||||
|
if ! command -v composer &> /dev/null; then
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
if [ -d "/tmp/moko-platform" ]; then
|
||||||
|
echo "moko-platform already available — skipping clone"
|
||||||
|
else
|
||||||
|
git clone --depth 1 --branch main --quiet \
|
||||||
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||||
|
/tmp/moko-platform 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||||
|
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate updates.xml entry
|
||||||
|
id: update
|
||||||
|
run: |
|
||||||
|
BRANCH="${{ github.ref_name }}"
|
||||||
|
REPO="${{ github.repository }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
VERSION=$(php /tmp/moko-platform/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||||
|
|
||||||
|
# Auto-bump patch on all branches (dev, alpha, beta, rc)
|
||||||
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
|
git config --local user.name "gitea-actions[bot]"
|
||||||
|
BUMPED=$(php /tmp/moko-platform/cli/version_bump.php --path . 2>/dev/null || true)
|
||||||
|
if [ -n "$BUMPED" ]; then
|
||||||
|
VERSION=$(php /tmp/moko-platform/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
|
||||||
|
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
|
||||||
|
git push 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine stability from branch or input
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
STABILITY="${{ inputs.stability }}"
|
||||||
|
elif [[ "$BRANCH" == rc/* ]]; then
|
||||||
|
STABILITY="rc"
|
||||||
|
elif [[ "$BRANCH" == beta/* ]]; then
|
||||||
|
STABILITY="beta"
|
||||||
|
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||||
|
STABILITY="alpha"
|
||||||
|
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
|
||||||
|
STABILITY="development"
|
||||||
|
else
|
||||||
|
STABILITY="stable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Parse manifest (portable — no grep -P)
|
||||||
|
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No Joomla manifest found — skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract fields using sed (works on all runners)
|
||||||
|
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
|
||||||
|
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||||
|
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
|
||||||
|
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||||
|
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||||
|
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||||
|
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
|
||||||
|
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
|
||||||
|
|
||||||
|
# Fallbacks
|
||||||
|
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
|
||||||
|
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
|
||||||
|
|
||||||
|
# Derive element if not in manifest: try XML filename, then repo name
|
||||||
|
if [ -z "$EXT_ELEMENT" ]; then
|
||||||
|
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||||
|
case "$EXT_ELEMENT" in
|
||||||
|
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use manifest version if README version is empty
|
||||||
|
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
|
||||||
|
|
||||||
|
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
|
||||||
|
|
||||||
|
# Joomla requires <client> on ALL extension types for update matching
|
||||||
|
if [ -n "$EXT_CLIENT" ]; then
|
||||||
|
CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||||
|
else
|
||||||
|
CLIENT_TAG="<client>site</client>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FOLDER_TAG=""
|
||||||
|
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
||||||
|
|
||||||
|
PHP_TAG=""
|
||||||
|
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
||||||
|
|
||||||
|
# VERSION already includes suffix from manifest (e.g. 01.02.14-dev)
|
||||||
|
# No separate DISPLAY_VERSION needed
|
||||||
|
|
||||||
|
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
||||||
|
|
||||||
|
# Each stability level has its own release tag
|
||||||
|
case "$STABILITY" in
|
||||||
|
development) RELEASE_TAG="development" ;;
|
||||||
|
alpha) RELEASE_TAG="alpha" ;;
|
||||||
|
beta) RELEASE_TAG="beta" ;;
|
||||||
|
rc) RELEASE_TAG="release-candidate" ;;
|
||||||
|
*) RELEASE_TAG="v${MAJOR}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
PACKAGE_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||||
|
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
||||||
|
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|
||||||
|
# -- Build install packages (ZIP + tar.gz) --------------------
|
||||||
|
SOURCE_DIR="src"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||||
|
if [ -d "$SOURCE_DIR" ]; then
|
||||||
|
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
|
||||||
|
TAR_NAME="${EXT_ELEMENT}-${VERSION}.tar.gz"
|
||||||
|
|
||||||
|
cd "$SOURCE_DIR"
|
||||||
|
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
|
||||||
|
cd ..
|
||||||
|
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
|
||||||
|
--exclude='.ftpignore' --exclude='sftp-config*' \
|
||||||
|
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
||||||
|
|
||||||
|
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
|
||||||
|
|
||||||
|
# Ensure release exists on Gitea
|
||||||
|
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
|
||||||
|
# Create release
|
||||||
|
RELEASE_JSON=$(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_TAG} (${VERSION})',
|
||||||
|
'body': '${STABILITY} release',
|
||||||
|
'prerelease': True,
|
||||||
|
'target_commitish': 'main'
|
||||||
|
}))")" 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)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
# Delete existing assets with same name before uploading
|
||||||
|
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||||
|
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
||||||
|
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
|
||||||
|
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
assets = json.load(sys.stdin)
|
||||||
|
for a in assets:
|
||||||
|
if a['name'] == '${ASSET_FILE}':
|
||||||
|
print(a['id']); break
|
||||||
|
" 2>/dev/null || true)
|
||||||
|
if [ -n "$ASSET_ID" ]; then
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||||
|
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Upload both formats
|
||||||
|
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"/tmp/${PACKAGE_NAME}" \
|
||||||
|
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"/tmp/${TAR_NAME}" \
|
||||||
|
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
SHA256=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Build the new entry (canonical format matching release.yml) --
|
||||||
|
NEW_ENTRY=""
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <update>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
|
||||||
|
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
|
||||||
|
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
|
||||||
|
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
|
||||||
|
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
|
||||||
|
NEW_ENTRY="${NEW_ENTRY} </update>"
|
||||||
|
|
||||||
|
# -- Write new entry to temp file --------------------------------
|
||||||
|
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
|
||||||
|
|
||||||
|
# -- Merge into updates.xml ----------------------------------------
|
||||||
|
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
|
||||||
|
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
|
||||||
|
TARGETS=""
|
||||||
|
for entry in $CASCADE_MAP; do
|
||||||
|
key="${entry%%:*}"
|
||||||
|
vals="${entry#*:}"
|
||||||
|
if [ "$key" = "${STABILITY}" ]; then
|
||||||
|
TARGETS="$vals"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
|
||||||
|
|
||||||
|
echo "Cascade: ${STABILITY} → ${TARGETS}"
|
||||||
|
|
||||||
|
# Create updates.xml if missing
|
||||||
|
if [ ! -f "updates.xml" ]; then
|
||||||
|
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
|
||||||
|
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
|
||||||
|
printf '%s\n' "<updates>" >> updates.xml
|
||||||
|
printf '%s\n' "</updates>" >> updates.xml
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update existing blocks or create missing ones
|
||||||
|
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
|
||||||
|
python3 << 'PYEOF'
|
||||||
|
import re, os
|
||||||
|
|
||||||
|
targets = os.environ["PY_TARGETS"].split(",")
|
||||||
|
version = os.environ["PY_VERSION"]
|
||||||
|
date = os.environ["PY_DATE"]
|
||||||
|
|
||||||
|
with open("updates.xml") as f:
|
||||||
|
content = f.read()
|
||||||
|
with open("/tmp/new_entry.xml") as f:
|
||||||
|
new_entry_template = f.read()
|
||||||
|
|
||||||
|
for tag in targets:
|
||||||
|
tag = tag.strip()
|
||||||
|
# Build entry with this tag's name
|
||||||
|
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
|
||||||
|
|
||||||
|
# Try to find existing block (handles both single-line and multi-line <tags>)
|
||||||
|
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
|
||||||
|
match = re.search(block_pattern, content, re.DOTALL)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
# Update in place — replace entire block
|
||||||
|
content = content.replace(match.group(1), new_entry.strip())
|
||||||
|
print(f" UPDATED: <tag>{tag}</tag> → {version}")
|
||||||
|
else:
|
||||||
|
# Create — insert before </updates>
|
||||||
|
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
|
||||||
|
print(f" CREATED: <tag>{tag}</tag> → {version}")
|
||||||
|
|
||||||
|
# Clean up excessive blank lines
|
||||||
|
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||||
|
|
||||||
|
with open("updates.xml", "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
|
git config --local user.name "gitea-actions[bot]"
|
||||||
|
git add updates.xml
|
||||||
|
git diff --cached --quiet || {
|
||||||
|
git commit -m "chore: update updates.xml (${STABILITY}: ${VERSION}) [skip ci]" \
|
||||||
|
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||||
|
git push
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Sync updates.xml to main (for non-main branches) ----------------------
|
||||||
|
- name: Sync updates.xml to main
|
||||||
|
if: github.ref_name != 'main'
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||||
|
|
||||||
|
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||||
|
python3 -c "
|
||||||
|
import base64, json, urllib.request, sys
|
||||||
|
with open('updates.xml', 'rb') as f:
|
||||||
|
content = base64.b64encode(f.read()).decode()
|
||||||
|
payload = json.dumps({
|
||||||
|
'content': content,
|
||||||
|
'sha': '${FILE_SHA}',
|
||||||
|
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
|
||||||
|
'branch': 'main'
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
'${API_BASE}/contents/updates.xml',
|
||||||
|
data=payload, method='PUT',
|
||||||
|
headers={
|
||||||
|
'Authorization': 'token ${GA_TOKEN}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
print('updates.xml synced to main')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: failed to sync updates.xml to main: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
" \
|
||||||
|
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|
||||||
|
|| echo "::error::failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "::error::could not get updates.xml SHA from main — file may not exist on main yet" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate updates.xml integrity
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
if [ ! -f "updates.xml" ]; then
|
||||||
|
echo "::error::updates.xml not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Well-formed XML
|
||||||
|
if ! python3 -c "import xml.etree.ElementTree as ET; ET.parse('updates.xml')" 2>/dev/null; then
|
||||||
|
echo "::error::updates.xml is not valid XML"
|
||||||
|
ERRORS=$((ERRORS+1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 << 'PYEOF'
|
||||||
|
import xml.etree.ElementTree as ET, sys, re, os
|
||||||
|
|
||||||
|
tree = ET.parse("updates.xml")
|
||||||
|
root = tree.getroot()
|
||||||
|
updates = root.findall("update")
|
||||||
|
errors = 0
|
||||||
|
warnings = 0
|
||||||
|
seen_tags = set()
|
||||||
|
|
||||||
|
# All 5 channels MUST be present
|
||||||
|
REQUIRED_CHANNELS = {"stable", "rc", "beta", "alpha", "dev"}
|
||||||
|
VALID_TAGS = REQUIRED_CHANNELS | {"development"} # accept legacy alias
|
||||||
|
REPO = os.environ.get("GITEA_REPO", "")
|
||||||
|
ORG = os.environ.get("GITEA_ORG", "MokoConsulting")
|
||||||
|
REPO_BASE = f"https://git.mokoconsulting.tech/{ORG}/"
|
||||||
|
|
||||||
|
# Gitea release tag names per channel (Moko standard)
|
||||||
|
RELEASE_TAG_MAP = {
|
||||||
|
"stable": "stable",
|
||||||
|
"rc": "release-candidate",
|
||||||
|
"beta": "beta",
|
||||||
|
"alpha": "alpha",
|
||||||
|
"dev": "development",
|
||||||
|
"development": "development",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Joomla update XML required fields per
|
||||||
|
# https://docs.joomla.org/Deploying_an_Update_Server
|
||||||
|
REQUIRED_FIELDS = ["name", "element", "type", "version", "infourl"]
|
||||||
|
|
||||||
|
for i, u in enumerate(updates):
|
||||||
|
tag_el = u.find("tags/tag")
|
||||||
|
tag = tag_el.text.strip() if tag_el is not None and tag_el.text else None
|
||||||
|
label = f"Entry {i+1} (<tag>{tag or '?'}</tag>)"
|
||||||
|
|
||||||
|
# -- Required Joomla fields --
|
||||||
|
for field in REQUIRED_FIELDS:
|
||||||
|
el = u.find(field)
|
||||||
|
if el is None or not (el.text or "").strip():
|
||||||
|
print(f"::error::{label}: missing required <{field}>")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
# -- <downloads><downloadurl> --
|
||||||
|
dl = u.find("downloads/downloadurl")
|
||||||
|
if dl is None or not (dl.text or "").strip():
|
||||||
|
print(f"::error::{label}: missing <downloads><downloadurl>")
|
||||||
|
errors += 1
|
||||||
|
else:
|
||||||
|
dl_url = dl.text.strip()
|
||||||
|
# Must point to org repo
|
||||||
|
if REPO_BASE not in dl_url:
|
||||||
|
print(f"::error::{label}: download URL not under {REPO_BASE}: {dl_url}")
|
||||||
|
errors += 1
|
||||||
|
# Must end in .zip
|
||||||
|
if not dl_url.endswith(".zip"):
|
||||||
|
print(f"::error::{label}: download URL must end in .zip: {dl_url}")
|
||||||
|
errors += 1
|
||||||
|
# Must use correct Gitea release tag in path
|
||||||
|
if tag and tag in RELEASE_TAG_MAP:
|
||||||
|
expected_tag = RELEASE_TAG_MAP[tag]
|
||||||
|
if f"/download/{expected_tag}/" not in dl_url:
|
||||||
|
print(f"::error::{label}: download URL should contain /download/{expected_tag}/ but got: {dl_url}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
# -- <client> (required for Joomla to match update) --
|
||||||
|
client = u.find("client")
|
||||||
|
if client is None or not (client.text or "").strip():
|
||||||
|
print(f"::error::{label}: missing <client> (required for Joomla update matching)")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
# -- <targetplatform> --
|
||||||
|
tp = u.find("targetplatform")
|
||||||
|
if tp is None:
|
||||||
|
print(f"::error::{label}: missing <targetplatform>")
|
||||||
|
errors += 1
|
||||||
|
else:
|
||||||
|
tp_name = tp.get("name", "")
|
||||||
|
tp_ver = tp.get("version", "")
|
||||||
|
if tp_name != "joomla":
|
||||||
|
print(f"::error::{label}: targetplatform name should be 'joomla', got '{tp_name}'")
|
||||||
|
errors += 1
|
||||||
|
if not tp_ver:
|
||||||
|
print(f"::error::{label}: targetplatform missing version regex")
|
||||||
|
errors += 1
|
||||||
|
elif "5" not in tp_ver or "6" not in tp_ver:
|
||||||
|
print(f"::warning::{label}: targetplatform version may not cover Joomla 5+6: {tp_ver}")
|
||||||
|
warnings += 1
|
||||||
|
|
||||||
|
# -- <type> must be valid Joomla type --
|
||||||
|
type_el = u.find("type")
|
||||||
|
if type_el is not None and type_el.text:
|
||||||
|
valid_types = {"component", "module", "plugin", "template", "library", "package", "file"}
|
||||||
|
if type_el.text.strip() not in valid_types:
|
||||||
|
print(f"::error::{label}: invalid type '{type_el.text}' (expected: {valid_types})")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
# -- <version> format (XX.YY.ZZ with optional suffix) --
|
||||||
|
ver_el = u.find("version")
|
||||||
|
if ver_el is not None and ver_el.text:
|
||||||
|
if not re.match(r"^\d{2}\.\d{2}\.\d{2}(-\w+)?$", ver_el.text.strip()):
|
||||||
|
print(f"::warning::{label}: version '{ver_el.text}' does not match XX.YY.ZZ format")
|
||||||
|
warnings += 1
|
||||||
|
|
||||||
|
# -- <maintainer> and <maintainerurl> --
|
||||||
|
for field in ["maintainer", "maintainerurl"]:
|
||||||
|
el = u.find(field)
|
||||||
|
if el is None or not (el.text or "").strip():
|
||||||
|
print(f"::warning::{label}: missing <{field}>")
|
||||||
|
warnings += 1
|
||||||
|
|
||||||
|
# -- Valid stability tag --
|
||||||
|
if tag is None:
|
||||||
|
print(f"::error::{label}: missing <tags><tag>")
|
||||||
|
errors += 1
|
||||||
|
elif tag not in VALID_TAGS:
|
||||||
|
print(f"::error::{label}: invalid tag '{tag}' (expected: {VALID_TAGS})")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
# -- Duplicate tag check --
|
||||||
|
norm_tag = "dev" if tag == "development" else tag
|
||||||
|
if norm_tag in seen_tags:
|
||||||
|
print(f"::error::{label}: duplicate channel '{tag}'")
|
||||||
|
errors += 1
|
||||||
|
if norm_tag:
|
||||||
|
seen_tags.add(norm_tag)
|
||||||
|
|
||||||
|
# -- All 5 channels must exist --
|
||||||
|
missing = REQUIRED_CHANNELS - seen_tags
|
||||||
|
if missing:
|
||||||
|
print(f"::error::Missing required update channels: {', '.join(sorted(missing))}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
# -- Version ordering: higher stability must not exceed dev version --
|
||||||
|
channel_versions = {}
|
||||||
|
for u in updates:
|
||||||
|
tag_el = u.find("tags/tag")
|
||||||
|
ver_el = u.find("version")
|
||||||
|
if tag_el is not None and ver_el is not None and tag_el.text and ver_el.text:
|
||||||
|
norm = "dev" if tag_el.text.strip() == "development" else tag_el.text.strip()
|
||||||
|
# Strip suffix for comparison (01.00.18-dev -> 01.00.18)
|
||||||
|
base_ver = re.sub(r"-\w+$", "", ver_el.text.strip())
|
||||||
|
channel_versions[norm] = base_ver
|
||||||
|
|
||||||
|
# Cascade check: dev >= alpha >= beta >= rc >= stable
|
||||||
|
ORDER = ["dev", "alpha", "beta", "rc", "stable"]
|
||||||
|
for j in range(1, len(ORDER)):
|
||||||
|
current = ORDER[j]
|
||||||
|
previous = ORDER[j - 1]
|
||||||
|
if current in channel_versions and previous in channel_versions:
|
||||||
|
if channel_versions[current] > channel_versions[previous]:
|
||||||
|
print(f"::error::{current} version ({channel_versions[current]}) is ahead of {previous} ({channel_versions[previous]})")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
# -- Summary --
|
||||||
|
print(f"\nupdates.xml validation: {len(updates)} entries, {errors} error(s), {warnings} warning(s)")
|
||||||
|
if errors > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
<!--
|
||||||
|
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]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `branch-cleanup.yml`: auto-delete merged feature branches after PR merge — synced to all 47 repos
|
||||||
|
- `governance.yml`: lightweight YAML schema replacing HCL definition files for repo governance config
|
||||||
|
- `auto-bump.yml`: auto patch-bump version on every push to dev
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Definitions removed**: deleted `definitions/` directory (63,602 lines of HCL) — Template repos are now the canonical source for platform-specific files
|
||||||
|
- **Template path migration**: `templates/gitea/` → `templates/mokogitea/`, all `.github/` references → `.mokogitea/` across definitions and sync tools
|
||||||
|
- `version_bump.php`: preserves version suffix (e.g. `-dev`) through bumps — moko manifest is now the single source of truth for the full version string (#191)
|
||||||
|
- `version_read.php`: accepts suffix from moko manifest (was stripping it)
|
||||||
|
- `update-server.yml`: removed `DISPLAY_VERSION` — derives filename directly from manifest version (#191)
|
||||||
|
- `pre-release.yml`: removed `SUFFIX` variable — version string already includes suffix
|
||||||
|
- `push_files.php`: detects platform from manifest.xml via API instead of local sync definition files
|
||||||
|
- `auto_detect_platform.php`: gracefully handles missing schema directory
|
||||||
|
- `DefinitionParser.php`: deleted — no longer needed
|
||||||
|
- `manifest-schema.xsd`: moved from `definitions/` to `templates/schemas/`
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `definitions/default/` — 10 HCL definition files (generic, joomla, dolibarr, platform, standards, etc.)
|
||||||
|
- `definitions/sync/` — 48 auto-generated sync tracking files
|
||||||
|
- `lib/Enterprise/DefinitionParser.php` — HCL parser (replaced by Template repo sourcing)
|
||||||
|
- Redundant bump from pre-release.yml (handled by auto-bump)
|
||||||
|
- 47 merged feature branches cleaned up from remote
|
||||||
|
|
||||||
|
## [09.02.00] - 2026-05-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Release promotion pipeline**: draft PR → RC promotion, merged PR → RC-to-stable (skip rebuild)
|
||||||
|
- **7 new CLI tools**: `manifest_element.php`, `release_create.php`, `release_package.php`, `release_promote.php`, `release_mirror.php`, `version_reset_dev.php`, `ManifestReader.php`
|
||||||
|
- `version_bump.php` / `version_read.php`: support for `package.json` (Node.js) and `pyproject.toml` (Python)
|
||||||
|
- `version_bump.php`: now writes bumped version to all sources (README, manifests, Dolibarr mod, composer.json, package.json, pyproject.toml)
|
||||||
|
- `release_cascade.php`: `--version` flag for version-aware deletion of stale releases
|
||||||
|
- `release_validate.php`: auto-detect platform from manifest.xml, `--github-output` flag, source dir check
|
||||||
|
- `updates_xml_build.php`: supports non-Joomla platforms via manifest.xml detection
|
||||||
|
- `release_package.php`: reads entry-point from manifest.xml for source dir resolution
|
||||||
|
- `auto-release.yml`: `workflow_dispatch` with `promote-rc` action as fallback for MokoGitea#220
|
||||||
|
- `update-server.yml`: now universal — pushed to all 69+ repos (Joomla, Dolibarr, generic, MCP)
|
||||||
|
- `ManifestReader.php`: shared typed accessor for `.mokogitea/manifest.xml`
|
||||||
|
- Universal workflow cascade: Template-Generic → other templates → all repos via `bulk_sync.php`
|
||||||
|
- Wiki: UPDATE_SERVER standard page on moko-platform and all template repos
|
||||||
|
- PHPDoc added to 4 classes missing class-level docs
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `auto-release.yml`: 761 → 490 lines — replaced all inline bash with CLI tool calls
|
||||||
|
- `pre-release.yml`: 389 → 314 lines — replaced inline logic with `manifest_read.php`, `manifest_element.php`, `updates_xml_build.php`
|
||||||
|
- Removed `paths` filter from workflow triggers (enables Go, Node.js, generic repo compatibility)
|
||||||
|
- `RepositorySynchronizer.php`: fixed template repo names, `.mokogitea/workflows` path, universal workflow sync
|
||||||
|
- Template-Generic is now the single source of truth for universal workflows
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `release_cascade.php` in `auto-release.yml`: was using `--org`/`--repo` flags instead of `--api-base`
|
||||||
|
- `pre-release.yml`: updates.xml sync was checking out entire branch tree instead of just `updates.xml`
|
||||||
|
- MokoWaaS#48: Joomla 6 typed event API fix for `plg_webservices_mokowaas`
|
||||||
|
|
||||||
|
## [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,102 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code when working with this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**moko-platform** — Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Language** | PHP 8.1+ |
|
||||||
|
| **Default branch** | main |
|
||||||
|
| **License** | GPL-3.0-or-later |
|
||||||
|
| **Version** | 09.01.00 |
|
||||||
|
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install # Install PHP dependencies
|
||||||
|
php bin/moko health --path . # Run repo health check
|
||||||
|
php bin/moko check:syntax --path . # PHP syntax check
|
||||||
|
php bin/moko drift --org MokoConsulting # Scan for standards drift
|
||||||
|
php bin/moko dashboard --token $TOKEN -o dashboard.html # Generate client dashboard
|
||||||
|
|
||||||
|
# Code quality
|
||||||
|
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
|
||||||
|
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
|
||||||
|
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
|
||||||
|
|
||||||
|
# Run all checks
|
||||||
|
composer check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Directory Layout
|
||||||
|
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
|
||||||
|
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
|
||||||
|
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
|
||||||
|
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
||||||
|
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
||||||
|
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
||||||
|
| `templates/` | Universal templates, configs, governance schema |
|
||||||
|
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
||||||
|
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
|
||||||
|
|
||||||
|
### CLI Framework
|
||||||
|
|
||||||
|
All CLI tools extend `MokoEnterprise\CliFramework` (defined in `lib/Enterprise/CliFramework.php`).
|
||||||
|
|
||||||
|
Pattern for new tools:
|
||||||
|
```php
|
||||||
|
class MyTool extends CliFramework {
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setDescription('What this tool does');
|
||||||
|
$this->addArgument('--name', 'Description', 'default');
|
||||||
|
}
|
||||||
|
protected function run(): int {
|
||||||
|
$name = $this->getArgument('--name');
|
||||||
|
// ... business logic ...
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$app = new MyTool();
|
||||||
|
exit($app->execute());
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`
|
||||||
|
|
||||||
|
### Platform Adapters
|
||||||
|
|
||||||
|
Git operations are abstracted via `GitPlatformAdapter` interface:
|
||||||
|
- `MokoGiteaAdapter` — for git.mokoconsulting.tech (primary)
|
||||||
|
- `GitHubAdapter` — for github.com mirrors
|
||||||
|
|
||||||
|
### Plugin System
|
||||||
|
|
||||||
|
Platform-specific logic lives in `lib/Enterprise/Plugins/`. Each plugin implements `ProjectPluginInterface` with methods for health checks, validation, build commands, and config schemas.
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
| Tool | Level | Config |
|
||||||
|
|------|-------|--------|
|
||||||
|
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
|
||||||
|
| PHPStan | Level 2 | `phpstan.neon` |
|
||||||
|
|
||||||
|
PHPStan runs with `--memory-limit=512M` due to large codebase. CI enforces PHPCS errors; PHPStan is advisory (`continue-on-error`).
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||||
|
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||||
|
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||||
|
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||||
|
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
|
||||||
|
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||||
|
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
|
||||||
|
- **After adding a CLI tool**: register it in `bin/moko` COMMAND_MAP
|
||||||
@@ -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
|
# Plugin System CLI Scripts
|
||||||
|
|
||||||
Command-line scripts for validating, health checking, and managing projects using the MokoStandards plugin system.
|
Command-line scripts for validating, health checking, and managing projects using the MokoStandards plugin system.
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
|
<!--
|
||||||
|
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
|
||||||
|
VERSION: 09.02.05
|
||||||
|
BRIEF: Project overview and documentation
|
||||||
|
-->
|
||||||
|
|
||||||
# MokoStandards Enterprise API
|
# MokoStandards Enterprise API
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
PHP implementation of MokoStandards — enterprise standards, automation framework, workflow templates, and bulk sync tooling.
|
PHP implementation of MokoStandards — enterprise standards, automation framework, workflow templates, and bulk sync tooling.
|
||||||
|
|
||||||
> **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API)
|
> **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API)
|
||||||
@@ -17,7 +31,7 @@ PHP implementation of MokoStandards — enterprise standards, automation framewo
|
|||||||
| `definitions/` | Repository structure definitions (`.tf` format) |
|
| `definitions/` | Repository structure definitions (`.tf` format) |
|
||||||
| `deploy/` | Deployment scripts (SFTP, Joomla) |
|
| `deploy/` | Deployment scripts (SFTP, Joomla) |
|
||||||
| `maintenance/` | Labels, inventory, SHA pinning, version sync |
|
| `maintenance/` | Labels, inventory, SHA pinning, version sync |
|
||||||
| `docs/` | API documentation, workflow guides, automation docs |
|
| `tools/` | Standalone tools (legal doc generator) |
|
||||||
| `tests/` | PHPUnit test suite |
|
| `tests/` | PHPUnit test suite |
|
||||||
|
|
||||||
## Installation
|
## 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
|
# Docs Index: /api/analysis
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|||||||
+569
-559
File diff suppressed because it is too large
Load Diff
+115
-95
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
@@ -10,9 +11,8 @@
|
|||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoStandards.Automation
|
||||||
* INGROUP: MokoStandards.Scripts
|
* INGROUP: MokoStandards.Scripts
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/bulk_sync.php
|
* PATH: /automation/bulk_sync.php
|
||||||
* VERSION: 04.06.00
|
|
||||||
* BRIEF: Enterprise-grade bulk repository synchronization
|
* BRIEF: Enterprise-grade bulk repository synchronization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ use MokoEnterprise\{
|
|||||||
AuditLogger,
|
AuditLogger,
|
||||||
CheckpointManager,
|
CheckpointManager,
|
||||||
CircuitBreakerOpen,
|
CircuitBreakerOpen,
|
||||||
CLIApp,
|
CliFramework,
|
||||||
Config,
|
Config,
|
||||||
GitPlatformAdapter,
|
GitPlatformAdapter,
|
||||||
MetricsCollector,
|
MetricsCollector,
|
||||||
@@ -41,18 +41,18 @@ use MokoEnterprise\{
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk Repository Synchronization Tool
|
* Bulk Repository Synchronization Tool
|
||||||
*
|
*
|
||||||
* Synchronizes MokoStandards files across multiple repositories using
|
* Synchronizes MokoStandards files across multiple repositories using
|
||||||
* the Enterprise library for robust, audited operations.
|
* the Enterprise library for robust, audited operations.
|
||||||
*/
|
*/
|
||||||
class BulkSync extends CLIApp
|
class BulkSync extends CliFramework
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Default organization for bulk sync operations
|
* Default organization for bulk sync operations
|
||||||
* Public to allow script instantiation with class constants
|
* Public to allow script instantiation with class constants
|
||||||
*/
|
*/
|
||||||
public const DEFAULT_ORG = 'MokoConsulting';
|
public const DEFAULT_ORG = 'MokoConsulting';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script version number
|
* Script version number
|
||||||
* Public to allow script instantiation with class constants
|
* Public to allow script instantiation with class constants
|
||||||
@@ -65,55 +65,52 @@ class BulkSync extends CLIApp
|
|||||||
private RepositorySynchronizer $synchronizer;
|
private RepositorySynchronizer $synchronizer;
|
||||||
private AuditLogger $logger;
|
private AuditLogger $logger;
|
||||||
private CheckpointManager $checkpoints;
|
private CheckpointManager $checkpoints;
|
||||||
private SecurityValidator $security;
|
private MetricsCollector $metrics;
|
||||||
private PluginFactory $pluginFactory;
|
|
||||||
private ProjectTypeDetector $typeDetector;
|
|
||||||
private Config $config;
|
private Config $config;
|
||||||
|
|
||||||
/** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */
|
/** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */
|
||||||
private bool $interrupted = false;
|
private bool $interrupted = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup command-line arguments
|
* Setup command-line arguments
|
||||||
*/
|
*/
|
||||||
protected function setupArguments(): array
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
return [
|
$this->setDescription('Bulk repository synchronization');
|
||||||
'org:' => 'GitHub organization (default: MokoConsulting)',
|
$this->addArgument('--org', 'Organization', self::DEFAULT_ORG);
|
||||||
'repos:' => 'Specific repositories to sync (space-separated)',
|
$this->addArgument('--repos', 'Specific repos', '');
|
||||||
'exclude:' => 'Repositories to exclude (space-separated)',
|
$this->addArgument('--exclude', 'Repos to exclude', '');
|
||||||
'skip-archived' => 'Skip archived repositories',
|
$this->addArgument('--skip-archived', 'Skip archived repos', false);
|
||||||
'yes' => 'Auto-confirm prompts',
|
$this->addArgument('--yes', 'Auto-confirm', false);
|
||||||
'resume' => 'Resume from last checkpoint, skipping already-processed repositories',
|
$this->addArgument('--resume', 'Resume from checkpoint', false);
|
||||||
'force' => 'Force overwrite of protected files (always_overwrite=false), except truly protected files',
|
$this->addArgument('--force', 'Force overwrite', false);
|
||||||
'protect' => 'Apply/enforce main branch protection rules on all synced repositories',
|
$this->addArgument('--protect', 'Apply branch protection', false);
|
||||||
'no-issue' => 'Skip creating a tracking issue in each target repository',
|
$this->addArgument('--no-issue', 'Skip tracking issue', false);
|
||||||
'update-branches' => 'After sync, merge main into all other open PR branches in each repo',
|
$this->addArgument('--update-branches', 'Merge main into branches', false);
|
||||||
'health' => 'Run repo health checks after sync and include results in the report',
|
$this->addArgument('--health', 'Run health checks', false);
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main execution
|
* Main execution
|
||||||
*/
|
*/
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$this->log("🚀 MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO');
|
$this->log("🚀 MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO');
|
||||||
|
|
||||||
// Initialize enterprise components
|
// Initialize enterprise components
|
||||||
if (!$this->initializeComponents()) {
|
if (!$this->initializeComponents()) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get configuration
|
// Get configuration
|
||||||
$org = $this->getOption('org', self::DEFAULT_ORG);
|
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||||
$skipArchived = $this->hasOption('skip-archived');
|
$skipArchived = $this->getArgument('--skip-archived', false);
|
||||||
$autoConfirm = $this->hasOption('yes');
|
$autoConfirm = $this->getArgument('--yes', false);
|
||||||
|
|
||||||
// Get repository filters
|
// Get repository filters
|
||||||
$specificRepos = $this->parseRepositoryList($this->getOption('repos', ''));
|
$specificRepos = $this->parseRepositoryList($this->getArgument('--repos', ''));
|
||||||
$excludeRepos = $this->parseRepositoryList($this->getOption('exclude', ''));
|
$excludeRepos = $this->parseRepositoryList($this->getArgument('--exclude', ''));
|
||||||
|
|
||||||
$this->log("Organization: {$org}", 'INFO');
|
$this->log("Organization: {$org}", 'INFO');
|
||||||
if (!empty($specificRepos)) {
|
if (!empty($specificRepos)) {
|
||||||
$this->log("Repositories: " . implode(', ', $specificRepos), 'INFO');
|
$this->log("Repositories: " . implode(', ', $specificRepos), 'INFO');
|
||||||
@@ -121,25 +118,25 @@ class BulkSync extends CLIApp
|
|||||||
if (!empty($excludeRepos)) {
|
if (!empty($excludeRepos)) {
|
||||||
$this->log("Excluding: " . implode(', ', $excludeRepos), 'INFO');
|
$this->log("Excluding: " . implode(', ', $excludeRepos), 'INFO');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get repositories
|
// Get repositories
|
||||||
$this->log("📋 Fetching repositories...", 'INFO');
|
$this->log("📋 Fetching repositories...", 'INFO');
|
||||||
$repositories = $this->synchronizer->getRepositories($org, $skipArchived);
|
$repositories = $this->synchronizer->getRepositories($org, $skipArchived);
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
$repositories = $this->filterRepositories($repositories, $specificRepos, $excludeRepos);
|
$repositories = $this->filterRepositories($repositories, $specificRepos, $excludeRepos);
|
||||||
|
|
||||||
$count = count($repositories);
|
$count = count($repositories);
|
||||||
$this->log("Found {$count} repositories to sync", 'INFO');
|
$this->log("Found {$count} repositories to sync", 'INFO');
|
||||||
|
|
||||||
if ($count === 0) {
|
if ($count === 0) {
|
||||||
$this->log("No repositories to process", 'WARN');
|
$this->log("No repositories to process", 'WARN');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load resume checkpoint if --resume is set
|
// Load resume checkpoint if --resume is set
|
||||||
$alreadyProcessed = [];
|
$alreadyProcessed = [];
|
||||||
if ($this->hasOption('resume')) {
|
if ($this->getArgument('--resume', false)) {
|
||||||
$checkpoint = $this->checkpoints->loadCheckpoint('bulk_sync');
|
$checkpoint = $this->checkpoints->loadCheckpoint('bulk_sync');
|
||||||
if ($checkpoint !== null) {
|
if ($checkpoint !== null) {
|
||||||
$alreadyProcessed = array_keys($checkpoint['results']['repositories'] ?? []);
|
$alreadyProcessed = array_keys($checkpoint['results']['repositories'] ?? []);
|
||||||
@@ -159,10 +156,15 @@ class BulkSync extends CLIApp
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync universal workflows from Template-Generic → other templates first
|
||||||
|
$this->log("📋 Syncing universal workflows to template repos...", 'INFO');
|
||||||
|
$templateUpdates = $this->synchronizer->syncUniversalWorkflowsToTemplates($org);
|
||||||
|
$this->log("Template sync: {$templateUpdates} file(s) updated", 'INFO');
|
||||||
|
|
||||||
// Execute synchronization
|
// Execute synchronization
|
||||||
$this->log("🔄 Starting synchronization...", 'INFO');
|
$this->log("🔄 Starting synchronization...", 'INFO');
|
||||||
$results = $this->executeSynchronization($org, $repositories, $alreadyProcessed);
|
$results = $this->executeSynchronization($org, $repositories, $alreadyProcessed);
|
||||||
|
|
||||||
// Display results
|
// Display results
|
||||||
$this->displayResults($results);
|
$this->displayResults($results);
|
||||||
|
|
||||||
@@ -188,7 +190,7 @@ class BulkSync extends CLIApp
|
|||||||
|
|
||||||
return $results['failed'] > 0 ? 1 : 0;
|
return $results['failed'] > 0 ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize enterprise components
|
* Initialize enterprise components
|
||||||
*/
|
*/
|
||||||
@@ -204,7 +206,6 @@ class BulkSync extends CLIApp
|
|||||||
$this->logger = new AuditLogger('bulk_sync');
|
$this->logger = new AuditLogger('bulk_sync');
|
||||||
$this->metrics = new MetricsCollector();
|
$this->metrics = new MetricsCollector();
|
||||||
$this->checkpoints = new CheckpointManager('.checkpoints');
|
$this->checkpoints = new CheckpointManager('.checkpoints');
|
||||||
$this->security = new SecurityValidator();
|
|
||||||
$this->synchronizer = new RepositorySynchronizer(
|
$this->synchronizer = new RepositorySynchronizer(
|
||||||
$this->api,
|
$this->api,
|
||||||
$this->logger,
|
$this->logger,
|
||||||
@@ -215,18 +216,15 @@ class BulkSync extends CLIApp
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Initialize plugin system
|
// 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');
|
$this->log("✓ Enterprise components initialized for platform: {$platform}", 'INFO');
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log("❌ Failed to initialize: " . $e->getMessage(), 'ERROR');
|
$this->log("❌ Failed to initialize: " . $e->getMessage(), 'ERROR');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse repository list from string
|
* Parse repository list from string
|
||||||
*/
|
*/
|
||||||
@@ -235,13 +233,13 @@ class BulkSync extends CLIApp
|
|||||||
if (empty($input)) {
|
if (empty($input)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_filter(
|
return array_filter(
|
||||||
array_map('trim', preg_split('/[\s,]+/', $input)),
|
array_map('trim', preg_split('/[\s,]+/', $input)),
|
||||||
fn($r) => !empty($r)
|
fn($r) => !empty($r)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter repositories based on include/exclude lists
|
* Filter repositories based on include/exclude lists
|
||||||
*/
|
*/
|
||||||
@@ -289,7 +287,7 @@ class BulkSync extends CLIApp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_merge($priority, $rest));
|
return array_merge($priority, $rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,11 +298,11 @@ class BulkSync extends CLIApp
|
|||||||
if ($this->quiet) {
|
if ($this->quiet) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "\n⚠️ About to synchronize {$count} repositories.\n";
|
echo "\n⚠️ About to synchronize {$count} repositories.\n";
|
||||||
echo "This will update files across all repositories.\n";
|
echo "This will update files across all repositories.\n";
|
||||||
echo "\nContinue? [y/N]: ";
|
echo "\nContinue? [y/N]: ";
|
||||||
|
|
||||||
$handle = fopen("php://stdin", "r");
|
$handle = fopen("php://stdin", "r");
|
||||||
$line = fgets($handle);
|
$line = fgets($handle);
|
||||||
if ($handle) {
|
if ($handle) {
|
||||||
@@ -315,7 +313,7 @@ class BulkSync extends CLIApp
|
|||||||
// treat that as a non-confirmation rather than crashing.
|
// treat that as a non-confirmation rather than crashing.
|
||||||
return is_string($line) && strtolower(trim($line)) === 'y';
|
return is_string($line) && strtolower(trim($line)) === 'y';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute synchronization across repositories
|
* Execute synchronization across repositories
|
||||||
*
|
*
|
||||||
@@ -344,8 +342,12 @@ class BulkSync extends CLIApp
|
|||||||
// instead of leaving the run in an unknown state.
|
// instead of leaving the run in an unknown state.
|
||||||
if (function_exists('pcntl_async_signals')) {
|
if (function_exists('pcntl_async_signals')) {
|
||||||
pcntl_async_signals(true);
|
pcntl_async_signals(true);
|
||||||
pcntl_signal(SIGINT, function () { $this->interrupted = true; });
|
pcntl_signal(SIGINT, function () {
|
||||||
pcntl_signal(SIGTERM, function () { $this->interrupted = true; });
|
$this->interrupted = true;
|
||||||
|
});
|
||||||
|
pcntl_signal(SIGTERM, function () {
|
||||||
|
$this->interrupted = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$startTime = microtime(true);
|
$startTime = microtime(true);
|
||||||
@@ -410,7 +412,6 @@ class BulkSync extends CLIApp
|
|||||||
$results['repositories'][$repoName] = 'skipped';
|
$results['repositories'][$repoName] = 'skipped';
|
||||||
$this->log(" ⊘ {$repoName} skipped", 'INFO');
|
$this->log(" ⊘ {$repoName} skipped", 'INFO');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (SynchronizationNotImplementedException $e) {
|
} catch (SynchronizationNotImplementedException $e) {
|
||||||
$this->log("", 'ERROR');
|
$this->log("", 'ERROR');
|
||||||
$this->log("╔══════════════════════════════════════════════════════════════════════════╗", 'ERROR');
|
$this->log("╔══════════════════════════════════════════════════════════════════════════╗", 'ERROR');
|
||||||
@@ -420,7 +421,7 @@ class BulkSync extends CLIApp
|
|||||||
$this->log("The bulk repository sync is failing silently because the core", 'ERROR');
|
$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("synchronization logic has not been implemented yet.", 'ERROR');
|
||||||
$this->log("", '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("Method: processRepository()", 'ERROR');
|
||||||
$this->log("", 'ERROR');
|
$this->log("", 'ERROR');
|
||||||
$this->log("Required Implementation:", 'ERROR');
|
$this->log("Required Implementation:", 'ERROR');
|
||||||
@@ -432,12 +433,10 @@ class BulkSync extends CLIApp
|
|||||||
$this->log("Until this is implemented, bulk sync will not function.", 'ERROR');
|
$this->log("Until this is implemented, bulk sync will not function.", 'ERROR');
|
||||||
$this->log("", 'ERROR');
|
$this->log("", 'ERROR');
|
||||||
throw $e;
|
throw $e;
|
||||||
|
|
||||||
} catch (CircuitBreakerOpen $e) {
|
} catch (CircuitBreakerOpen $e) {
|
||||||
$results['failed']++;
|
$results['failed']++;
|
||||||
$results['repositories'][$repoName] = 'failed';
|
$results['repositories'][$repoName] = 'failed';
|
||||||
$this->log(" ✗ {$repoName} failed: Circuit breaker open - " . $e->getMessage(), 'ERROR');
|
$this->log(" ✗ {$repoName} failed: Circuit breaker open - " . $e->getMessage(), 'ERROR');
|
||||||
|
|
||||||
} catch (RateLimitExceeded $e) {
|
} catch (RateLimitExceeded $e) {
|
||||||
// Rate limit hit — abort immediately so we don't burn retries on 403s
|
// Rate limit hit — abort immediately so we don't burn retries on 403s
|
||||||
$results['failed']++;
|
$results['failed']++;
|
||||||
@@ -445,7 +444,6 @@ class BulkSync extends CLIApp
|
|||||||
$this->log(" ✗ {$repoName} rate-limited: " . $e->getMessage(), 'ERROR');
|
$this->log(" ✗ {$repoName} rate-limited: " . $e->getMessage(), 'ERROR');
|
||||||
$this->saveInterruptCheckpoint($results, $repoName, 'rate_limited');
|
$this->saveInterruptCheckpoint($results, $repoName, 'rate_limited');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Also catch rate limits surfaced as generic exceptions by ApiClient retries
|
// Also catch rate limits surfaced as generic exceptions by ApiClient retries
|
||||||
if ($this->isRateLimitError($e)) {
|
if ($this->isRateLimitError($e)) {
|
||||||
@@ -509,12 +507,12 @@ class BulkSync extends CLIApp
|
|||||||
]);
|
]);
|
||||||
$script = basename(__FILE__);
|
$script = basename(__FILE__);
|
||||||
$this->log("💾 Checkpoint saved. To resume once the issue is resolved, run:", 'INFO');
|
$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) {
|
} catch (\Exception $e) {
|
||||||
$this->log("⚠️ Failed to save interrupt checkpoint: " . $e->getMessage(), 'WARN');
|
$this->log("⚠️ Failed to save interrupt checkpoint: " . $e->getMessage(), 'WARN');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display synchronization results
|
* Display synchronization results
|
||||||
*/
|
*/
|
||||||
@@ -523,22 +521,22 @@ class BulkSync extends CLIApp
|
|||||||
$this->log("\n" . str_repeat('=', 60), 'INFO');
|
$this->log("\n" . str_repeat('=', 60), 'INFO');
|
||||||
$this->log("📊 Synchronization Complete", 'INFO');
|
$this->log("📊 Synchronization Complete", 'INFO');
|
||||||
$this->log(str_repeat('=', 60), 'INFO');
|
$this->log(str_repeat('=', 60), 'INFO');
|
||||||
|
|
||||||
$total = $results['total'];
|
$total = $results['total'];
|
||||||
$success = $results['success'];
|
$success = $results['success'];
|
||||||
$skipped = $results['skipped'];
|
$skipped = $results['skipped'];
|
||||||
$failed = $results['failed'];
|
$failed = $results['failed'];
|
||||||
$duration = $results['duration'];
|
$duration = $results['duration'];
|
||||||
|
|
||||||
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
|
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
|
||||||
|
|
||||||
$this->log(sprintf("Total: %d repositories", $total), 'INFO');
|
$this->log(sprintf("Total: %d repositories", $total), 'INFO');
|
||||||
$this->log(sprintf("Success: %d (✓)", $success), 'INFO');
|
$this->log(sprintf("Success: %d (✓)", $success), 'INFO');
|
||||||
$this->log(sprintf("Skipped: %d (⊘)", $skipped), 'INFO');
|
$this->log(sprintf("Skipped: %d (⊘)", $skipped), 'INFO');
|
||||||
$this->log(sprintf("Failed: %d (✗)", $failed), 'INFO');
|
$this->log(sprintf("Failed: %d (✗)", $failed), 'INFO');
|
||||||
$this->log(sprintf("Success Rate: %.1f%%", $successRate), 'INFO');
|
$this->log(sprintf("Success Rate: %.1f%%", $successRate), 'INFO');
|
||||||
$this->log(sprintf("Duration: %.2f seconds", $duration), 'INFO');
|
$this->log(sprintf("Duration: %.2f seconds", $duration), 'INFO');
|
||||||
|
|
||||||
if ($failed > 0) {
|
if ($failed > 0) {
|
||||||
$this->log("\n⚠️ Failed Repositories:", 'WARN');
|
$this->log("\n⚠️ Failed Repositories:", 'WARN');
|
||||||
foreach ($results['repositories'] as $repo => $status) {
|
foreach ($results['repositories'] as $repo => $status) {
|
||||||
@@ -547,11 +545,11 @@ class BulkSync extends CLIApp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->verbose) {
|
if ($this->verbose) {
|
||||||
$this->log("\n📋 Repository Details:", 'INFO');
|
$this->log("\n📋 Repository Details:", 'INFO');
|
||||||
foreach ($results['repositories'] as $repo => $status) {
|
foreach ($results['repositories'] as $repo => $status) {
|
||||||
$icon = match($status) {
|
$icon = match ($status) {
|
||||||
'success' => '✓',
|
'success' => '✓',
|
||||||
'skipped' => '⊘',
|
'skipped' => '⊘',
|
||||||
'failed' => '✗',
|
'failed' => '✗',
|
||||||
@@ -560,12 +558,12 @@ class BulkSync extends CLIApp
|
|||||||
$this->log(sprintf(" %s %s: %s", $icon, $repo, $status), 'INFO');
|
$this->log(sprintf(" %s %s: %s", $icon, $repo, $status), 'INFO');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->log(str_repeat('=', 60), 'INFO');
|
$this->log(str_repeat('=', 60), 'INFO');
|
||||||
|
|
||||||
$this->writeStepSummary($results);
|
$this->writeStepSummary($results);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write synchronization results to the GitHub Actions step summary.
|
* Write synchronization results to the GitHub Actions step summary.
|
||||||
*
|
*
|
||||||
@@ -588,7 +586,7 @@ class BulkSync extends CLIApp
|
|||||||
if (empty($summaryFile)) {
|
if (empty($summaryFile)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that the path is an absolute filesystem path and not a
|
// Validate that the path is an absolute filesystem path and not a
|
||||||
// special device file, to guard against environment variable injection.
|
// special device file, to guard against environment variable injection.
|
||||||
$realDir = realpath(dirname($summaryFile));
|
$realDir = realpath(dirname($summaryFile));
|
||||||
@@ -596,14 +594,14 @@ class BulkSync extends CLIApp
|
|||||||
$this->log('⚠️ GITHUB_STEP_SUMMARY path is not safe, skipping step summary write.', 'WARN');
|
$this->log('⚠️ GITHUB_STEP_SUMMARY path is not safe, skipping step summary write.', 'WARN');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$total = $results['total'];
|
$total = $results['total'];
|
||||||
$success = $results['success'];
|
$success = $results['success'];
|
||||||
$skipped = $results['skipped'];
|
$skipped = $results['skipped'];
|
||||||
$failed = $results['failed'];
|
$failed = $results['failed'];
|
||||||
$duration = $results['duration'];
|
$duration = $results['duration'];
|
||||||
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
|
$successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0;
|
||||||
|
|
||||||
$lines = [];
|
$lines = [];
|
||||||
$lines[] = '';
|
$lines[] = '';
|
||||||
$lines[] = '### 📊 Synchronization Summary';
|
$lines[] = '### 📊 Synchronization Summary';
|
||||||
@@ -620,7 +618,7 @@ class BulkSync extends CLIApp
|
|||||||
$duration
|
$duration
|
||||||
);
|
);
|
||||||
$lines[] = '';
|
$lines[] = '';
|
||||||
|
|
||||||
if (!empty($results['repositories'])) {
|
if (!empty($results['repositories'])) {
|
||||||
$lines[] = '### 📋 Repositories Processed';
|
$lines[] = '### 📋 Repositories Processed';
|
||||||
$lines[] = '';
|
$lines[] = '';
|
||||||
@@ -637,7 +635,7 @@ class BulkSync extends CLIApp
|
|||||||
}
|
}
|
||||||
$lines[] = '';
|
$lines[] = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$written = file_put_contents($summaryFile, implode("\n", $lines) . "\n", FILE_APPEND);
|
$written = file_put_contents($summaryFile, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||||
if ($written === false) {
|
if ($written === false) {
|
||||||
$this->log('⚠️ Failed to write to GITHUB_STEP_SUMMARY.', 'WARN');
|
$this->log('⚠️ Failed to write to GITHUB_STEP_SUMMARY.', 'WARN');
|
||||||
@@ -737,8 +735,10 @@ class BulkSync extends CLIApp
|
|||||||
if (str_contains($protName, 'version') || $this->refsContain($refs, 'version')) {
|
if (str_contains($protName, 'version') || $this->refsContain($refs, 'version')) {
|
||||||
$hasVersion = true;
|
$hasVersion = true;
|
||||||
}
|
}
|
||||||
if ((str_contains($protName, 'dev') && !str_contains($protName, 'develop'))
|
if (
|
||||||
|| $this->refsContain($refs, 'dev')) {
|
(str_contains($protName, 'dev') && !str_contains($protName, 'develop'))
|
||||||
|
|| $this->refsContain($refs, 'dev')
|
||||||
|
) {
|
||||||
$hasDev = true;
|
$hasDev = true;
|
||||||
}
|
}
|
||||||
if (str_contains($protName, 'rc') || $this->refsContain($refs, 'rc/')) {
|
if (str_contains($protName, 'rc') || $this->refsContain($refs, 'rc/')) {
|
||||||
@@ -746,10 +746,18 @@ class BulkSync extends CLIApp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasMain) { $score += 5; }
|
if ($hasMain) {
|
||||||
if ($hasVersion) { $score += 5; }
|
$score += 5;
|
||||||
if ($hasDev) { $score += 5; }
|
}
|
||||||
if ($hasRc) { $score += 5; }
|
if ($hasVersion) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
if ($hasDev) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
if ($hasRc) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->api->resetCircuitBreaker();
|
$this->api->resetCircuitBreaker();
|
||||||
}
|
}
|
||||||
@@ -757,7 +765,9 @@ class BulkSync extends CLIApp
|
|||||||
// 2. Check branch protection on main (10 pts)
|
// 2. Check branch protection on main (10 pts)
|
||||||
$max += 10;
|
$max += 10;
|
||||||
$hasMainProtection = $this->checkBranchProtected($org, $name);
|
$hasMainProtection = $this->checkBranchProtected($org, $name);
|
||||||
if ($hasMainProtection) { $score += 10; }
|
if ($hasMainProtection) {
|
||||||
|
$score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate level
|
// Calculate level
|
||||||
$pct = $max > 0 ? ($score / $max * 100) : 0;
|
$pct = $max > 0 ? ($score / $max * 100) : 0;
|
||||||
@@ -783,7 +793,10 @@ class BulkSync extends CLIApp
|
|||||||
$poor = count(array_filter($health, fn($h) => $h['level'] === 'poor'));
|
$poor = count(array_filter($health, fn($h) => $h['level'] === 'poor'));
|
||||||
$this->log(sprintf(
|
$this->log(sprintf(
|
||||||
"🩺 Health: %d excellent, %d good, %d fair, %d poor",
|
"🩺 Health: %d excellent, %d good, %d fair, %d poor",
|
||||||
$excellent, $good, $fair, $poor
|
$excellent,
|
||||||
|
$good,
|
||||||
|
$fair,
|
||||||
|
$poor
|
||||||
), 'INFO');
|
), 'INFO');
|
||||||
|
|
||||||
return $health;
|
return $health;
|
||||||
@@ -1018,7 +1031,9 @@ class BulkSync extends CLIApp
|
|||||||
try {
|
try {
|
||||||
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
|
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
|
||||||
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
|
$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", [
|
$prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [
|
||||||
'state' => 'open',
|
'state' => 'open',
|
||||||
@@ -1048,7 +1063,7 @@ class BulkSync extends CLIApp
|
|||||||
if (str_contains($msg, '409') || str_contains($msg, 'Merge conflict')) {
|
if (str_contains($msg, '409') || str_contains($msg, 'Merge conflict')) {
|
||||||
$this->log(" ⚠️ Merge conflict: {$defaultBranch} → {$branch} (PR #{$prNum})", 'WARN');
|
$this->log(" ⚠️ Merge conflict: {$defaultBranch} → {$branch} (PR #{$prNum})", 'WARN');
|
||||||
} elseif (str_contains($msg, '204') || str_contains($msg, 'nothing to merge')) {
|
} 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 {
|
} else {
|
||||||
$this->log(" ⚠️ Could not merge into {$branch}: " . $msg, 'WARN');
|
$this->log(" ⚠️ Could not merge into {$branch}: " . $msg, 'WARN');
|
||||||
}
|
}
|
||||||
@@ -1147,6 +1162,7 @@ class BulkSync extends CLIApp
|
|||||||
'sort' => 'created',
|
'sort' => 'created',
|
||||||
'direction' => 'desc',
|
'direction' => 'desc',
|
||||||
]);
|
]);
|
||||||
|
$existing = array_values($existing);
|
||||||
|
|
||||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||||
$num = $existing[0]['number'];
|
$num = $existing[0]['number'];
|
||||||
@@ -1158,7 +1174,9 @@ class BulkSync extends CLIApp
|
|||||||
// Re-apply labels in case any were removed
|
// Re-apply labels in case any were removed
|
||||||
try {
|
try {
|
||||||
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
|
$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');
|
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
|
||||||
} else {
|
} else {
|
||||||
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
|
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
|
||||||
@@ -1182,7 +1200,9 @@ class BulkSync extends CLIApp
|
|||||||
'body' => $closeRef . "\n\n" . $currentBody,
|
'body' => $closeRef . "\n\n" . $currentBody,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (\Exception $le) { /* non-fatal */ }
|
} catch (\Exception $le) {
|
||||||
|
/* non-fatal */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return is_int($num) ? $num : null;
|
return is_int($num) ? $num : null;
|
||||||
@@ -1286,11 +1306,12 @@ class BulkSync extends CLIApp
|
|||||||
'state' => 'all',
|
'state' => 'all',
|
||||||
'per_page' => 1,
|
'per_page' => 1,
|
||||||
'sort' => 'created',
|
'sort' => 'created',
|
||||||
'direction'=> 'desc',
|
'direction' => 'desc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation'];
|
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation'];
|
||||||
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames);
|
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames);
|
||||||
|
$existing = array_values($existing);
|
||||||
|
|
||||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||||
$issueNumber = $existing[0]['number'];
|
$issueNumber = $existing[0]['number'];
|
||||||
@@ -1301,7 +1322,9 @@ class BulkSync extends CLIApp
|
|||||||
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch);
|
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch);
|
||||||
try {
|
try {
|
||||||
$this->api->post("/repos/{$org}/MokoStandards/issues/{$issueNumber}/labels", ['labels' => $labels]);
|
$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');
|
$this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO');
|
||||||
} else {
|
} else {
|
||||||
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
||||||
@@ -1355,7 +1378,7 @@ class BulkSync extends CLIApp
|
|||||||
|
|
||||||
1. Check the local audit log or re-run with `--repos=<repo>` to see the specific error.
|
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.).
|
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.
|
4. Close this issue once all repos are synced successfully.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1372,6 +1395,7 @@ class BulkSync extends CLIApp
|
|||||||
'sort' => 'created',
|
'sort' => 'created',
|
||||||
'direction' => 'desc',
|
'direction' => 'desc',
|
||||||
]);
|
]);
|
||||||
|
$existing = array_values($existing);
|
||||||
|
|
||||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||||
$num = $existing[0]['number'];
|
$num = $existing[0]['number'];
|
||||||
@@ -1399,10 +1423,6 @@ class BulkSync extends CLIApp
|
|||||||
|
|
||||||
// Execute if run directly
|
// Execute if run directly
|
||||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
||||||
$app = new BulkSync(
|
$app = new BulkSync();
|
||||||
'bulk-sync',
|
|
||||||
'Enterprise-grade bulk repository synchronization',
|
|
||||||
BulkSync::VERSION
|
|
||||||
);
|
|
||||||
exit($app->execute());
|
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
|
# Docs Index: /api/automation
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|||||||
+222
-218
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
* This file is part of a Moko Consulting project.
|
* This file is part of a Moko Consulting project.
|
||||||
@@ -9,16 +10,15 @@
|
|||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoStandards.Automation
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: MokoStandards
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/migrate_to_gitea.php
|
* PATH: /automation/migrate_to_gitea.php
|
||||||
* VERSION: 04.06.10
|
|
||||||
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
|
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
|
||||||
*
|
*
|
||||||
* USAGE
|
* USAGE
|
||||||
* php api/automation/migrate_to_gitea.php --dry-run
|
* php automation/migrate_to_gitea.php --dry-run
|
||||||
* php api/automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
|
* php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
|
||||||
* php api/automation/migrate_to_gitea.php --exclude MokoStandards --skip-archived
|
* php automation/migrate_to_gitea.php --exclude MokoStandards --skip-archived
|
||||||
* php api/automation/migrate_to_gitea.php --resume
|
* php automation/migrate_to_gitea.php --resume
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -30,7 +30,7 @@ use MokoEnterprise\CliFramework;
|
|||||||
use MokoEnterprise\Config;
|
use MokoEnterprise\Config;
|
||||||
use MokoEnterprise\PlatformAdapterFactory;
|
use MokoEnterprise\PlatformAdapterFactory;
|
||||||
use MokoEnterprise\GitHubAdapter;
|
use MokoEnterprise\GitHubAdapter;
|
||||||
use MokoEnterprise\GiteaAdapter;
|
use MokoEnterprise\MokoGiteaAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gitea Migration Script
|
* Gitea Migration Script
|
||||||
@@ -42,254 +42,258 @@ use MokoEnterprise\GiteaAdapter;
|
|||||||
*/
|
*/
|
||||||
class MigrateToGitea extends CliFramework
|
class MigrateToGitea extends CliFramework
|
||||||
{
|
{
|
||||||
private ?GitHubAdapter $github = null;
|
private ?GitHubAdapter $github = null;
|
||||||
private ?GiteaAdapter $gitea = null;
|
private ?MokoGiteaAdapter $gitea = null;
|
||||||
private ?CheckpointManager $checkpoints = null;
|
private ?CheckpointManager $checkpoints = null;
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setDescription('Migrate repositories from GitHub to Gitea');
|
$this->setDescription('Migrate repositories from GitHub to Gitea');
|
||||||
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
|
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
|
||||||
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
|
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
|
||||||
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
|
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
|
||||||
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
|
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
|
||||||
$this->addArgument('--resume', 'Resume from last checkpoint', false);
|
$this->addArgument('--resume', 'Resume from last checkpoint', false);
|
||||||
$this->addArgument('--github-token', 'GitHub token override', '');
|
$this->addArgument('--github-token', 'GitHub token override', '');
|
||||||
$this->addArgument('--gitea-token', 'Gitea token override', '');
|
$this->addArgument('--gitea-token', 'Gitea token override', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function run(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
$dryRun = (bool) $this->getArgument('--dry-run');
|
||||||
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
|
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
|
||||||
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
|
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
|
||||||
$skipArchived = (bool) $this->getArgument('--skip-archived');
|
$skipArchived = (bool) $this->getArgument('--skip-archived');
|
||||||
$resume = (bool) $this->getArgument('--resume');
|
$resume = (bool) $this->getArgument('--resume');
|
||||||
|
|
||||||
$config = Config::load();
|
$config = Config::load();
|
||||||
|
|
||||||
// Override tokens if provided
|
// Override tokens if provided
|
||||||
$ghToken = (string) $this->getArgument('--github-token');
|
$ghToken = (string) $this->getArgument('--github-token');
|
||||||
$giteaToken = (string) $this->getArgument('--gitea-token');
|
$giteaToken = (string) $this->getArgument('--gitea-token');
|
||||||
if ($ghToken !== '') { $config->set('github.token', $ghToken); }
|
if ($ghToken !== '') {
|
||||||
if ($giteaToken !== '') { $config->set('gitea.token', $giteaToken); }
|
$config->set('github.token', $ghToken);
|
||||||
|
}
|
||||||
|
if ($giteaToken !== '') {
|
||||||
|
$config->set('gitea.token', $giteaToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Create both adapters
|
// Create both adapters
|
||||||
try {
|
try {
|
||||||
$adapters = PlatformAdapterFactory::createBoth($config);
|
$adapters = PlatformAdapterFactory::createBoth($config);
|
||||||
$this->github = $adapters['github'];
|
$this->github = $adapters['github'];
|
||||||
$this->gitea = $adapters['gitea'];
|
$this->gitea = $adapters['gitea'];
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
$this->log('ERROR', $e->getMessage());
|
$this->log('ERROR', $e->getMessage());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
|
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
|
||||||
$org = $config->getString('github.organization', 'MokoConsulting');
|
$org = $config->getString('github.organization', 'MokoConsulting');
|
||||||
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
|
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
|
||||||
|
|
||||||
echo "=== Gitea Migration Tool ===\n";
|
echo "=== Gitea Migration Tool ===\n";
|
||||||
echo "Source: GitHub ({$org})\n";
|
echo "Source: GitHub ({$org})\n";
|
||||||
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
|
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
|
||||||
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
|
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
|
||||||
|
|
||||||
// ── Phase 1: Discovery ──────────────────────────────────────────
|
// ── Phase 1: Discovery ──────────────────────────────────────────
|
||||||
$this->section('Phase 1: Discovery');
|
$this->section('Phase 1: Discovery');
|
||||||
|
|
||||||
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
|
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
|
||||||
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
|
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
|
||||||
|
|
||||||
// Filter repos
|
// Filter repos
|
||||||
if (!empty($specificRepos)) {
|
if (!empty($specificRepos)) {
|
||||||
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
|
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
|
||||||
}
|
}
|
||||||
if (!empty($excludeRepos)) {
|
if (!empty($excludeRepos)) {
|
||||||
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
|
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check which already exist on Gitea
|
// Check which already exist on Gitea
|
||||||
$giteaRepos = [];
|
$giteaRepos = [];
|
||||||
try {
|
try {
|
||||||
$existing = $this->gitea->listOrgRepos($giteaOrg);
|
$existing = $this->gitea->listOrgRepos($giteaOrg);
|
||||||
foreach ($existing as $r) {
|
foreach ($existing as $r) {
|
||||||
$giteaRepos[$r['name']] = true;
|
$giteaRepos[$r['name']] = true;
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
|
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
$toMigrate = [];
|
$toMigrate = [];
|
||||||
$toSkip = [];
|
$toSkip = [];
|
||||||
foreach ($ghRepos as $repo) {
|
foreach ($ghRepos as $repo) {
|
||||||
$name = $repo['name'];
|
$name = $repo['name'];
|
||||||
if (isset($giteaRepos[$name])) {
|
if (isset($giteaRepos[$name])) {
|
||||||
$toSkip[] = $name;
|
$toSkip[] = $name;
|
||||||
} else {
|
} else {
|
||||||
$toMigrate[] = $repo;
|
$toMigrate[] = $repo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "\nMigration plan:\n";
|
echo "\nMigration plan:\n";
|
||||||
echo " Migrate: " . count($toMigrate) . " repositories\n";
|
echo " Migrate: " . count($toMigrate) . " repositories\n";
|
||||||
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
|
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
|
||||||
if (!empty($toSkip)) {
|
if (!empty($toSkip)) {
|
||||||
echo " Skipped: " . implode(', ', $toSkip) . "\n";
|
echo " Skipped: " . implode(', ', $toSkip) . "\n";
|
||||||
}
|
}
|
||||||
echo "\n";
|
echo "\n";
|
||||||
|
|
||||||
if (empty($toMigrate)) {
|
if (empty($toMigrate)) {
|
||||||
echo "Nothing to migrate.\n";
|
echo "Nothing to migrate.\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
echo "Repositories to migrate:\n";
|
echo "Repositories to migrate:\n";
|
||||||
foreach ($toMigrate as $repo) {
|
foreach ($toMigrate as $repo) {
|
||||||
$vis = $repo['private'] ? 'private' : 'public';
|
$vis = $repo['private'] ? 'private' : 'public';
|
||||||
echo " - {$repo['name']} ({$vis})\n";
|
echo " - {$repo['name']} ({$vis})\n";
|
||||||
}
|
}
|
||||||
echo "\nDry run complete. Use without --dry-run to execute.\n";
|
echo "\nDry run complete. Use without --dry-run to execute.\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 2: Migrate ────────────────────────────────────────────
|
// ── Phase 2: Migrate ────────────────────────────────────────────
|
||||||
$this->section('Phase 2: Migration');
|
$this->section('Phase 2: Migration');
|
||||||
|
|
||||||
$ghToken = $config->getString('github.token');
|
$ghToken = $config->getString('github.token');
|
||||||
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
|
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
|
||||||
|
|
||||||
// Resume support
|
// Resume support
|
||||||
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
|
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
|
||||||
$startFrom = $checkpoint['last_completed'] ?? '';
|
$startFrom = $checkpoint['last_completed'] ?? '';
|
||||||
$skipUntil = !empty($startFrom);
|
$skipUntil = !empty($startFrom);
|
||||||
|
|
||||||
foreach ($toMigrate as $index => $repo) {
|
foreach ($toMigrate as $index => $repo) {
|
||||||
$name = $repo['name'];
|
$name = $repo['name'];
|
||||||
|
|
||||||
if ($skipUntil) {
|
if ($skipUntil) {
|
||||||
if ($name === $startFrom) {
|
if ($name === $startFrom) {
|
||||||
$skipUntil = false;
|
$skipUntil = false;
|
||||||
}
|
}
|
||||||
echo " Skipping {$name} (already migrated)\n";
|
echo " Skipping {$name} (already migrated)\n";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
|
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Shallow migration — copy current branch state only, no past
|
// Shallow migration — copy current branch state only, no past
|
||||||
// commit history. This gives every repo a clean start on Gitea.
|
// commit history. This gives every repo a clean start on Gitea.
|
||||||
$this->gitea->migrateRepository([
|
$this->gitea->migrateRepository([
|
||||||
'clone_addr' => "https://github.com/{$org}/{$name}.git",
|
'clone_addr' => "https://github.com/{$org}/{$name}.git",
|
||||||
'repo_name' => $name,
|
'repo_name' => $name,
|
||||||
'repo_owner' => $giteaOrg,
|
'repo_owner' => $giteaOrg,
|
||||||
'service' => 'github',
|
'service' => 'github',
|
||||||
'auth_token' => $ghToken,
|
'auth_token' => $ghToken,
|
||||||
'mirror' => false,
|
'mirror' => false,
|
||||||
'private' => $repo['private'],
|
'private' => $repo['private'],
|
||||||
'issues' => false,
|
'issues' => false,
|
||||||
'labels' => true,
|
'labels' => true,
|
||||||
'milestones' => false,
|
'milestones' => false,
|
||||||
'releases' => false,
|
'releases' => false,
|
||||||
'pull_requests' => false,
|
'pull_requests' => false,
|
||||||
'wiki' => false,
|
'wiki' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
echo " Migrated successfully\n";
|
echo " Migrated successfully\n";
|
||||||
$results['migrated'][] = $name;
|
$results['migrated'][] = $name;
|
||||||
|
|
||||||
// Save checkpoint after each successful migration
|
// Save checkpoint after each successful migration
|
||||||
$this->checkpoints->saveCheckpoint('gitea_migration', [
|
$this->checkpoints->saveCheckpoint('gitea_migration', [
|
||||||
'last_completed' => $name,
|
'last_completed' => $name,
|
||||||
'migrated' => $results['migrated'],
|
'migrated' => $results['migrated'],
|
||||||
'failed' => $results['failed'],
|
'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) {
|
// ── Phase 3: Post-migration ─────────────────────────────────────
|
||||||
echo " FAILED: " . $e->getMessage() . "\n";
|
$this->section('Phase 3: Post-migration');
|
||||||
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
|
|
||||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 3: Post-migration ─────────────────────────────────────
|
foreach ($results['migrated'] as $name) {
|
||||||
$this->section('Phase 3: Post-migration');
|
echo " Post-processing {$name}...\n";
|
||||||
|
|
||||||
foreach ($results['migrated'] as $name) {
|
try {
|
||||||
echo " Post-processing {$name}...\n";
|
// 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 branch protection
|
||||||
// Apply topics from GitHub
|
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
|
||||||
$ghTopics = $this->github->getRepoTopics($org, $name);
|
'required_reviews' => 1,
|
||||||
if (!empty($ghTopics)) {
|
'dismiss_stale' => true,
|
||||||
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
|
'block_on_rejected' => true,
|
||||||
echo " Topics applied\n";
|
]);
|
||||||
}
|
echo " Branch protection applied\n";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
|
||||||
|
$this->gitea->getApiClient()->resetCircuitBreaker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply branch protection
|
// ── Phase 4: Verification ───────────────────────────────────────
|
||||||
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
|
$this->section('Phase 4: Verification');
|
||||||
'required_reviews' => 1,
|
|
||||||
'dismiss_stale' => true,
|
|
||||||
'block_on_rejected' => true,
|
|
||||||
]);
|
|
||||||
echo " Branch protection applied\n";
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
$report = "## Migration Report\n\n";
|
||||||
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
|
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
|
||||||
$this->gitea->getApiClient()->resetCircuitBreaker();
|
$report .= "**Source:** GitHub ({$org})\n";
|
||||||
}
|
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 4: Verification ───────────────────────────────────────
|
$report .= "### Results\n\n";
|
||||||
$this->section('Phase 4: Verification');
|
$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";
|
if (!empty($results['migrated'])) {
|
||||||
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
|
$report .= "### Migrated Repositories\n\n";
|
||||||
$report .= "**Source:** GitHub ({$org})\n";
|
foreach ($results['migrated'] as $name) {
|
||||||
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
|
$report .= "- {$name}\n";
|
||||||
|
}
|
||||||
|
$report .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
$report .= "### Results\n\n";
|
if (!empty($results['failed'])) {
|
||||||
$report .= "| Status | Count |\n|--------|-------|\n";
|
$report .= "### Failed Repositories\n\n";
|
||||||
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
|
foreach ($results['failed'] as $fail) {
|
||||||
$report .= "| Failed | " . count($results['failed']) . " |\n";
|
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
|
||||||
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
|
}
|
||||||
|
$report .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
if (!empty($results['migrated'])) {
|
echo $report;
|
||||||
$report .= "### Migrated Repositories\n\n";
|
|
||||||
foreach ($results['migrated'] as $name) {
|
|
||||||
$report .= "- {$name}\n";
|
|
||||||
}
|
|
||||||
$report .= "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($results['failed'])) {
|
// Create summary issue on Gitea
|
||||||
$report .= "### Failed Repositories\n\n";
|
try {
|
||||||
foreach ($results['failed'] as $fail) {
|
$this->gitea->createIssue(
|
||||||
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
|
$giteaOrg,
|
||||||
}
|
'MokoStandards',
|
||||||
$report .= "\n";
|
'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
|
return count($results['failed']) > 0 ? 1 : 0;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea');
|
$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea');
|
||||||
|
|||||||
+78
-84
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
@@ -10,9 +11,8 @@
|
|||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoStandards.Automation
|
||||||
* INGROUP: MokoStandards.Scripts
|
* INGROUP: MokoStandards.Scripts
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/push_files.php
|
* PATH: /automation/push_files.php
|
||||||
* VERSION: 04.06.00
|
|
||||||
* BRIEF: Push one or more specific files to one or more remote repositories
|
* BRIEF: Push one or more specific files to one or more remote repositories
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,9 +24,8 @@ require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
|||||||
use MokoEnterprise\{
|
use MokoEnterprise\{
|
||||||
ApiClient,
|
ApiClient,
|
||||||
AuditLogger,
|
AuditLogger,
|
||||||
CLIApp,
|
CliFramework,
|
||||||
Config,
|
Config,
|
||||||
DefinitionParser,
|
|
||||||
GitPlatformAdapter,
|
GitPlatformAdapter,
|
||||||
MetricsCollector,
|
MetricsCollector,
|
||||||
PlatformAdapterFactory,
|
PlatformAdapterFactory,
|
||||||
@@ -51,32 +50,30 @@ use MokoEnterprise\{
|
|||||||
* php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent
|
* 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
|
* 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 DEFAULT_ORG = 'MokoConsulting';
|
||||||
public const VERSION = '04.06.00';
|
public const VERSION = '04.06.00';
|
||||||
|
|
||||||
private ApiClient $api;
|
private ApiClient $api;
|
||||||
private GitPlatformAdapter $adapter;
|
private GitPlatformAdapter $adapter;
|
||||||
private AuditLogger $logger;
|
private AuditLogger $logger;
|
||||||
private DefinitionParser $defParser;
|
private ProjectTypeDetector $typeDetector;
|
||||||
private ProjectTypeDetector $typeDetector;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup command-line arguments
|
* Setup command-line arguments
|
||||||
*/
|
*/
|
||||||
protected function setupArguments(): array
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
return [
|
$this->setDescription('Push files to remote repositories');
|
||||||
'org:' => 'GitHub organization (default: ' . self::DEFAULT_ORG . ')',
|
$this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG);
|
||||||
'repos:' => 'Target repositories — comma or space-separated (required)',
|
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
|
||||||
'files:' => 'Files to push — destination paths or source:destination pairs, comma/space-separated (required)',
|
$this->addArgument('--files', 'Files to push (comma-separated)', '');
|
||||||
'message:' => 'Custom commit message (optional)',
|
$this->addArgument('--message', 'Custom commit message', '');
|
||||||
'branch:' => 'Target branch for direct pushes (default: repo default branch). Ignored unless --direct is set',
|
$this->addArgument('--branch', 'Target branch for direct pushes', '');
|
||||||
'direct' => 'Push directly to target branch instead of creating a PR',
|
$this->addArgument('--direct', 'Push directly instead of PR', false);
|
||||||
'yes' => 'Auto-confirm without prompting',
|
$this->addArgument('--yes', 'Auto-confirm without prompting', false);
|
||||||
'no-issue' => 'Skip creating a tracking issue in each target repository',
|
$this->addArgument('--no-issue', 'Skip creating tracking issue', false);
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,11 +87,11 @@ class PushFiles extends CLIApp
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$org = $this->getOption('org', self::DEFAULT_ORG);
|
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||||
$reposArg = $this->getOption('repos', '');
|
$reposArg = $this->getArgument('--repos', '');
|
||||||
$filesArg = $this->getOption('files', '');
|
$filesArg = $this->getArgument('--files', '');
|
||||||
$direct = $this->hasOption('direct');
|
$direct = $this->getArgument('--direct', false);
|
||||||
$autoYes = $this->hasOption('yes');
|
$autoYes = $this->getArgument('--yes', false);
|
||||||
|
|
||||||
// Validate required arguments
|
// Validate required arguments
|
||||||
if (empty($reposArg)) {
|
if (empty($reposArg)) {
|
||||||
@@ -127,7 +124,7 @@ class PushFiles extends CLIApp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Confirm before proceeding
|
// Confirm before proceeding
|
||||||
if (!$autoYes && !$this->confirm($repoFileMaps, $direct)) {
|
if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) {
|
||||||
$this->log('❌ Cancelled.', 'INFO');
|
$this->log('❌ Cancelled.', 'INFO');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -155,7 +152,6 @@ class PushFiles extends CLIApp
|
|||||||
$this->adapter = PlatformAdapterFactory::create($config);
|
$this->adapter = PlatformAdapterFactory::create($config);
|
||||||
$this->api = $this->adapter->getApiClient();
|
$this->api = $this->adapter->getApiClient();
|
||||||
$this->logger = new AuditLogger('push_files');
|
$this->logger = new AuditLogger('push_files');
|
||||||
$this->defParser = new DefinitionParser();
|
|
||||||
$this->typeDetector = new ProjectTypeDetector($this->logger);
|
$this->typeDetector = new ProjectTypeDetector($this->logger);
|
||||||
|
|
||||||
$platform = $this->adapter->getPlatformName();
|
$platform = $this->adapter->getPlatformName();
|
||||||
@@ -199,43 +195,24 @@ class PushFiles extends CLIApp
|
|||||||
$platform = $this->detectRepoPlatform($org, $repo);
|
$platform = $this->detectRepoPlatform($org, $repo);
|
||||||
$this->log(" {$repo}: platform = {$platform}", 'INFO');
|
$this->log(" {$repo}: platform = {$platform}", 'INFO');
|
||||||
|
|
||||||
// Build a destination→source lookup from the definition
|
|
||||||
$defEntries = $this->defParser->parseForPlatform($platform, $repoRoot);
|
|
||||||
$destToSource = [];
|
|
||||||
foreach ($defEntries as $entry) {
|
|
||||||
$destToSource[$entry['destination']] = $entry['source'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolved = [];
|
$resolved = [];
|
||||||
foreach ($files as $fileSpec) {
|
foreach ($files as $fileSpec) {
|
||||||
if (str_contains($fileSpec, ':')) {
|
if (str_contains($fileSpec, ':')) {
|
||||||
// Raw source:destination pair
|
// Raw source:destination pair
|
||||||
[$src, $dest] = explode(':', $fileSpec, 2);
|
[$src, $dest] = explode(':', $fileSpec, 2);
|
||||||
$srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/');
|
|
||||||
if (!file_exists($srcAbs)) {
|
|
||||||
$this->log(" ⚠️ Source not found for {$repo}: {$src}", 'WARN');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$resolved[] = ['source' => $srcAbs, 'destination' => $dest];
|
|
||||||
$this->log(" ✓ {$dest} (raw: {$src})", 'INFO');
|
|
||||||
} else {
|
} else {
|
||||||
// Destination path — look up in definition
|
// Same path as source and destination
|
||||||
$dest = ltrim($fileSpec, '/');
|
$src = $fileSpec;
|
||||||
if (isset($destToSource[$dest])) {
|
$dest = $fileSpec;
|
||||||
$src = $destToSource[$dest];
|
|
||||||
$srcAbs = str_starts_with($src, '/')
|
|
||||||
? $src
|
|
||||||
: rtrim($repoRoot, '/') . '/' . ltrim($src, '/');
|
|
||||||
if (!file_exists($srcAbs)) {
|
|
||||||
$this->log(" ⚠️ Template not found for {$repo}: {$src}", 'WARN');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$resolved[] = ['source' => $srcAbs, 'destination' => $dest];
|
|
||||||
$this->log(" ✓ {$dest}", 'INFO');
|
|
||||||
} else {
|
|
||||||
$this->log(" ⚠️ {$dest} not found in {$platform} definition for {$repo}", 'WARN');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
$dest = ltrim($dest, '/');
|
||||||
|
$srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/');
|
||||||
|
if (!file_exists($srcAbs)) {
|
||||||
|
$this->log(" ⚠️ Source not found for {$repo}: {$src}", 'WARN');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$resolved[] = ['source' => $srcAbs, 'destination' => $dest];
|
||||||
|
$this->log(" ✓ {$dest}", 'INFO');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($resolved)) {
|
if (!empty($resolved)) {
|
||||||
@@ -247,25 +224,30 @@ class PushFiles extends CLIApp
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect platform for a repo by checking its sync def file, falling back
|
* Detect platform for a repo via manifest or live detection.
|
||||||
* to the live GitHub API detection used by bulk_sync.
|
|
||||||
*/
|
*/
|
||||||
private function detectRepoPlatform(string $org, string $repo): string
|
private function detectRepoPlatform(string $org, string $repo): string
|
||||||
{
|
{
|
||||||
// Check local sync def first — fastest path
|
// Read platform from repo's .mokogitea/manifest.xml via API
|
||||||
$defDir = dirname(__DIR__) . '/definitions/sync';
|
try {
|
||||||
$defFile = "{$defDir}/{$repo}.def.tf";
|
$manifestData = $this->adapter->getFileContent($org, $repo, '.mokogitea/manifest.xml', 'main');
|
||||||
if (file_exists($defFile)) {
|
if (!empty($manifestData)) {
|
||||||
$content = file_get_contents($defFile) ?: '';
|
$xml = @simplexml_load_string($manifestData);
|
||||||
if (preg_match('/detected_platform\s*=\s*"([^"]+)"/', $content, $m)) {
|
if ($xml !== false) {
|
||||||
return $m[1];
|
$platform = (string)($xml->governance->platform ?? '');
|
||||||
|
if (!empty($platform)) {
|
||||||
|
return $platform;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fall through to local detection
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to live detection
|
// Fall back to live detection
|
||||||
try {
|
try {
|
||||||
$repoData = $this->api->get("/repos/{$org}/{$repo}");
|
$result = $this->typeDetector->detect('.');
|
||||||
return $this->typeDetector->detect($repoData, $org, $repo);
|
return $result['type'] ?? 'default';
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN');
|
$this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN');
|
||||||
return 'default';
|
return 'default';
|
||||||
@@ -277,7 +259,7 @@ class PushFiles extends CLIApp
|
|||||||
*
|
*
|
||||||
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
|
* @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) {
|
if ($this->quiet) {
|
||||||
return true;
|
return true;
|
||||||
@@ -322,8 +304,8 @@ class PushFiles extends CLIApp
|
|||||||
'repos' => [],
|
'repos' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
$customMessage = $this->getOption('message', '');
|
$customMessage = $this->getArgument('--message', '');
|
||||||
$targetBranch = $this->getOption('branch', '');
|
$targetBranch = $this->getArgument('--branch', '');
|
||||||
|
|
||||||
foreach ($repoFileMaps as $repo => $entries) {
|
foreach ($repoFileMaps as $repo => $entries) {
|
||||||
$this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO');
|
$this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO');
|
||||||
@@ -357,7 +339,12 @@ class PushFiles extends CLIApp
|
|||||||
$prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards";
|
$prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards";
|
||||||
$prBody = $this->buildPRBody($entries);
|
$prBody = $this->buildPRBody($entries);
|
||||||
$pr = $this->adapter->createPullRequest(
|
$pr = $this->adapter->createPullRequest(
|
||||||
$org, $repo, $prTitle, $branch, $defaultBranch, $prBody,
|
$org,
|
||||||
|
$repo,
|
||||||
|
$prTitle,
|
||||||
|
$branch,
|
||||||
|
$defaultBranch,
|
||||||
|
$prBody,
|
||||||
['assignees' => ['jmiller']]
|
['assignees' => ['jmiller']]
|
||||||
);
|
);
|
||||||
$prNumber = $pr['number'] ?? null;
|
$prNumber = $pr['number'] ?? null;
|
||||||
@@ -372,7 +359,6 @@ class PushFiles extends CLIApp
|
|||||||
}
|
}
|
||||||
|
|
||||||
$results['success']++;
|
$results['success']++;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log(" ✗ {$repo}: " . $e->getMessage(), 'ERROR');
|
$this->log(" ✗ {$repo}: " . $e->getMessage(), 'ERROR');
|
||||||
$results['failed']++;
|
$results['failed']++;
|
||||||
@@ -441,7 +427,13 @@ class PushFiles extends CLIApp
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$this->adapter->createOrUpdateFile(
|
$this->adapter->createOrUpdateFile(
|
||||||
$org, $repo, $destPath, $content, $message, $existingSha, $branch
|
$org,
|
||||||
|
$repo,
|
||||||
|
$destPath,
|
||||||
|
$content,
|
||||||
|
$message,
|
||||||
|
$existingSha,
|
||||||
|
$branch
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -510,6 +502,7 @@ class PushFiles extends CLIApp
|
|||||||
'direction' => 'desc',
|
'direction' => 'desc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$existing = array_values($existing);
|
||||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||||
$num = $existing[0]['number'];
|
$num = $existing[0]['number'];
|
||||||
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
||||||
@@ -519,7 +512,9 @@ class PushFiles extends CLIApp
|
|||||||
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch);
|
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch);
|
||||||
try {
|
try {
|
||||||
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
|
$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');
|
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
|
||||||
} else {
|
} else {
|
||||||
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
|
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
|
||||||
@@ -544,7 +539,9 @@ class PushFiles extends CLIApp
|
|||||||
'body' => $ref . "\n\n" . $currentBody,
|
'body' => $ref . "\n\n" . $currentBody,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (\Exception $le) { /* non-fatal */ }
|
} catch (\Exception $le) {
|
||||||
|
/* non-fatal */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN');
|
$this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN');
|
||||||
@@ -567,7 +564,7 @@ class PushFiles extends CLIApp
|
|||||||
));
|
));
|
||||||
|
|
||||||
$repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos));
|
$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";
|
$title = "fix: push_files failed for {$failed} repo(s) — action required";
|
||||||
|
|
||||||
@@ -590,7 +587,7 @@ class PushFiles extends CLIApp
|
|||||||
|
|
||||||
1. Check the output above for the specific error per repo.
|
1. Check the output above for the specific error per repo.
|
||||||
2. Fix the underlying issue (API token, branch permissions, file path, etc.).
|
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.
|
4. Close this issue once resolved.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -608,6 +605,7 @@ class PushFiles extends CLIApp
|
|||||||
'direction' => 'desc',
|
'direction' => 'desc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$existing = array_values($existing);
|
||||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||||
$num = $existing[0]['number'];
|
$num = $existing[0]['number'];
|
||||||
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
||||||
@@ -679,10 +677,6 @@ class PushFiles extends CLIApp
|
|||||||
|
|
||||||
// Execute if run directly
|
// Execute if run directly
|
||||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
||||||
$app = new PushFiles(
|
$app = new PushFiles();
|
||||||
'push-files',
|
|
||||||
'Push one or more specific files to one or more remote repositories',
|
|
||||||
PushFiles::VERSION
|
|
||||||
);
|
|
||||||
exit($app->execute());
|
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
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
@@ -10,9 +11,8 @@
|
|||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.Automation
|
* DEFGROUP: MokoStandards.Automation
|
||||||
* INGROUP: MokoStandards.Scripts
|
* INGROUP: MokoStandards.Scripts
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /automation/repo_cleanup.php
|
* PATH: /automation/repo_cleanup.php
|
||||||
* VERSION: 04.06.00
|
|
||||||
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
|
* 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__ . '/../vendor/autoload.php';
|
||||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.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
|
* Enterprise Repository Cleanup
|
||||||
@@ -36,7 +36,7 @@ use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, GitPlatformAdapter,
|
|||||||
* 7. Verify and provision standard labels
|
* 7. Verify and provision standard labels
|
||||||
* 8. Version drift detection
|
* 8. Version drift detection
|
||||||
*/
|
*/
|
||||||
class RepoCleanup extends CLIApp
|
class RepoCleanup extends CliFramework
|
||||||
{
|
{
|
||||||
private const VERSION = '04.06.00';
|
private const VERSION = '04.06.00';
|
||||||
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
|
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
|
||||||
@@ -56,44 +56,36 @@ class RepoCleanup extends CLIApp
|
|||||||
'deploy-rs.yml',
|
'deploy-rs.yml',
|
||||||
];
|
];
|
||||||
|
|
||||||
private ApiClient $api;
|
private ApiClient $api;
|
||||||
private GitPlatformAdapter $adapter;
|
private GitPlatformAdapter $adapter;
|
||||||
private AuditLogger $logger;
|
protected bool $dryRun = false;
|
||||||
private MetricsCollector $metrics;
|
private float $startTime;
|
||||||
private bool $dryRun = false;
|
|
||||||
private float $startTime;
|
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->setName('repo-cleanup');
|
$this->setDescription('Enterprise repository cleanup');
|
||||||
$this->setDescription('Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs');
|
$this->addArgument('--org', 'GitHub organization', 'MokoConsulting');
|
||||||
$this->setVersion(self::VERSION);
|
$this->addArgument('--repos', 'Specific repos (space-separated)', '');
|
||||||
|
$this->addArgument('--skip-archived', 'Skip archived repos', false);
|
||||||
$this->addOption('org', 'GitHub organization', 'MokoConsulting');
|
$this->addArgument('--close-issues', 'Close resolved tracking issues', false);
|
||||||
$this->addOption('repos', 'Specific repositories (space-separated)', '');
|
$this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false);
|
||||||
$this->addOption('skip-archived', 'Skip archived repositories', false);
|
$this->addArgument('--clean-workflows', 'Delete stale workflow runs', false);
|
||||||
$this->addOption('close-issues', 'Close resolved tracking issues (merged PR = done)', false);
|
$this->addArgument('--clean-logs', 'Delete old workflow logs', false);
|
||||||
$this->addOption('lock-old-issues', 'Lock issues closed >30 days', false);
|
$this->addArgument('--log-days', 'Days to keep logs', '30');
|
||||||
$this->addOption('clean-workflows', 'Delete cancelled/stale workflow runs', false);
|
$this->addArgument('--delete-retired', 'Delete retired workflows', false);
|
||||||
$this->addOption('clean-logs', 'Delete workflow run logs older than --log-days', false);
|
$this->addArgument('--check-labels', 'Verify labels exist', false);
|
||||||
$this->addOption('log-days', 'Days to keep logs (default: 30)', '30');
|
$this->addArgument('--check-drift', 'Check version drift', false);
|
||||||
$this->addOption('delete-retired', 'Delete retired workflow files from repos', false);
|
$this->addArgument('--all', 'Run all operations', false);
|
||||||
$this->addOption('check-labels', 'Verify mokostandards label exists', false);
|
$this->addArgument('--yes', 'Auto-confirm', false);
|
||||||
$this->addOption('check-drift', 'Check for version drift against README.md', false);
|
$this->addArgument('--json', 'Output as JSON', 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(): int
|
protected function run(): int
|
||||||
{
|
{
|
||||||
$this->startTime = microtime(true);
|
$this->startTime = microtime(true);
|
||||||
$org = $this->getOption('org', 'MokoConsulting');
|
$org = $this->getArgument('--org', 'MokoConsulting');
|
||||||
$this->dryRun = (bool) $this->getOption('dry-run', false);
|
$this->dryRun = (bool) $this->getArgument('--dry-run', false);
|
||||||
$runAll = (bool) $this->getOption('all', false);
|
$runAll = (bool) $this->getArgument('--all', false);
|
||||||
|
|
||||||
$config = Config::load();
|
$config = Config::load();
|
||||||
|
|
||||||
@@ -101,24 +93,22 @@ class RepoCleanup extends CLIApp
|
|||||||
$this->adapter = PlatformAdapterFactory::create($config);
|
$this->adapter = PlatformAdapterFactory::create($config);
|
||||||
$this->api = $this->adapter->getApiClient();
|
$this->api = $this->adapter->getApiClient();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error('Failed to initialize platform adapter: ' . $e->getMessage());
|
$this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger = new AuditLogger('repo_cleanup');
|
|
||||||
$this->metrics = new MetricsCollector('repo_cleanup');
|
|
||||||
|
|
||||||
$this->log("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
||||||
$this->log("Organization: {$org}");
|
$this->logMsg("Organization: {$org}");
|
||||||
$this->log("Current sync branch: " . self::CURRENT_BRANCH);
|
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
|
||||||
if ($this->dryRun) {
|
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);
|
$repos = $this->fetchRepositories($org);
|
||||||
$this->log("Found " . count($repos) . " repositories");
|
$this->logMsg("Found " . count($repos) . " repositories");
|
||||||
$this->log('');
|
$this->logMsg('');
|
||||||
|
|
||||||
$results = [
|
$results = [
|
||||||
'repos_processed' => 0,
|
'repos_processed' => 0,
|
||||||
@@ -140,7 +130,7 @@ class RepoCleanup extends CLIApp
|
|||||||
$name = $repo['name'];
|
$name = $repo['name'];
|
||||||
$num = $i + 1;
|
$num = $i + 1;
|
||||||
$total = count($repos);
|
$total = count($repos);
|
||||||
$this->log("[{$num}/{$total}] {$name}");
|
$this->logMsg("[{$num}/{$total}] {$name}");
|
||||||
$results['repos_processed']++;
|
$results['repos_processed']++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -151,37 +141,37 @@ class RepoCleanup extends CLIApp
|
|||||||
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
|
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
|
||||||
|
|
||||||
// Optional: close resolved issues
|
// 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;
|
$cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: lock old closed issues
|
// 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;
|
$cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: delete retired workflow files
|
// 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;
|
$cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: clean workflow runs
|
// 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;
|
$cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: clean old logs
|
// 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;
|
$cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: check labels
|
// Optional: check labels
|
||||||
if ($runAll || $this->getOption('check-labels', false)) {
|
if ($runAll || $this->getArgument('--check-labels', false)) {
|
||||||
$this->checkLabels($org, $name, $results);
|
$this->checkLabels($org, $name, $results);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: check version drift
|
// Optional: check version drift
|
||||||
if ($runAll || $this->getOption('check-drift', false)) {
|
if ($runAll || $this->getArgument('--check-drift', false)) {
|
||||||
$this->checkVersionDrift($org, $name, $results);
|
$this->checkVersionDrift($org, $name, $results);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,32 +179,32 @@ class RepoCleanup extends CLIApp
|
|||||||
$results['repos_cleaned']++;
|
$results['repos_cleaned']++;
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error(" ✗ {$name}: " . $e->getMessage());
|
$this->errorMsg(" ✗ {$name}: " . $e->getMessage());
|
||||||
$results['errors']++;
|
$results['errors']++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$duration = round(microtime(true) - $this->startTime, 1);
|
$duration = round(microtime(true) - $this->startTime, 1);
|
||||||
|
|
||||||
$this->log('');
|
$this->logMsg('');
|
||||||
$this->log('============================================================');
|
$this->logMsg('============================================================');
|
||||||
$this->log("🧹 Cleanup Complete ({$duration}s)");
|
$this->logMsg("🧹 Cleanup Complete ({$duration}s)");
|
||||||
$this->log('============================================================');
|
$this->logMsg('============================================================');
|
||||||
$this->log("Repos processed: {$results['repos_processed']}");
|
$this->logMsg("Repos processed: {$results['repos_processed']}");
|
||||||
$this->log("Repos with changes: {$results['repos_cleaned']}");
|
$this->logMsg("Repos with changes: {$results['repos_cleaned']}");
|
||||||
$this->log("Branches deleted: {$results['branches_deleted']}");
|
$this->logMsg("Branches deleted: {$results['branches_deleted']}");
|
||||||
$this->log("PRs closed: {$results['prs_closed']}");
|
$this->logMsg("PRs closed: {$results['prs_closed']}");
|
||||||
$this->log("Issues closed: {$results['issues_closed']}");
|
$this->logMsg("Issues closed: {$results['issues_closed']}");
|
||||||
$this->log("Issues locked: {$results['issues_locked']}");
|
$this->logMsg("Issues locked: {$results['issues_locked']}");
|
||||||
$this->log("Retired files: {$results['retired_files']}");
|
$this->logMsg("Retired files: {$results['retired_files']}");
|
||||||
$this->log("Workflow runs: {$results['runs_deleted']}");
|
$this->logMsg("Workflow runs: {$results['runs_deleted']}");
|
||||||
$this->log("Logs cleaned: {$results['logs_deleted']}");
|
$this->logMsg("Logs cleaned: {$results['logs_deleted']}");
|
||||||
$this->log("Labels missing: {$results['labels_missing']}");
|
$this->logMsg("Labels missing: {$results['labels_missing']}");
|
||||||
$this->log("Version drift: {$results['version_drift']}");
|
$this->logMsg("Version drift: {$results['version_drift']}");
|
||||||
$this->log("Errors: {$results['errors']}");
|
$this->logMsg("Errors: {$results['errors']}");
|
||||||
$this->log('============================================================');
|
$this->logMsg('============================================================');
|
||||||
|
|
||||||
if ($this->getOption('json', false)) {
|
if ($this->getArgument('--json', false)) {
|
||||||
$results['duration_seconds'] = $duration;
|
$results['duration_seconds'] = $duration;
|
||||||
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
|
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
|
||||||
}
|
}
|
||||||
@@ -226,8 +216,8 @@ class RepoCleanup extends CLIApp
|
|||||||
|
|
||||||
private function fetchRepositories(string $org): array
|
private function fetchRepositories(string $org): array
|
||||||
{
|
{
|
||||||
$specificRepos = trim((string) $this->getOption('repos', ''));
|
$specificRepos = trim((string) $this->getArgument('--repos', ''));
|
||||||
$skipArchived = (bool) $this->getOption('skip-archived', false);
|
$skipArchived = (bool) $this->getArgument('--skip-archived', false);
|
||||||
|
|
||||||
if (!empty($specificRepos)) {
|
if (!empty($specificRepos)) {
|
||||||
$names = preg_split('/[\s,]+/', $specificRepos);
|
$names = preg_split('/[\s,]+/', $specificRepos);
|
||||||
@@ -264,18 +254,22 @@ class RepoCleanup extends CLIApp
|
|||||||
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
|
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
|
||||||
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']);
|
$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']++;
|
$results['prs_closed']++;
|
||||||
$changed = true;
|
$changed = true;
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) { /* non-fatal */ }
|
} catch (\Exception $e) {
|
||||||
|
/* non-fatal */
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->dryRun) {
|
if (!$this->dryRun) {
|
||||||
try {
|
try {
|
||||||
$this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}");
|
$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']++;
|
$results['branches_deleted']++;
|
||||||
$changed = true;
|
$changed = true;
|
||||||
}
|
}
|
||||||
@@ -291,7 +285,9 @@ class RepoCleanup extends CLIApp
|
|||||||
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
||||||
'labels' => $label, 'state' => 'open', 'per_page' => 10,
|
'labels' => $label, 'state' => 'open', 'per_page' => 10,
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) { continue; }
|
} catch (\Exception $e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($issues as $issue) {
|
foreach ($issues as $issue) {
|
||||||
$num = $issue['number'] ?? 0;
|
$num = $issue['number'] ?? 0;
|
||||||
@@ -306,11 +302,13 @@ class RepoCleanup extends CLIApp
|
|||||||
'state' => 'closed', 'state_reason' => 'completed',
|
'state' => 'closed', 'state_reason' => 'completed',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$this->log(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
|
$this->logMsg(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
|
||||||
$results['issues_closed']++;
|
$results['issues_closed']++;
|
||||||
$changed = true;
|
$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", [
|
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
||||||
'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc',
|
'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc',
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) { return false; }
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($issues as $issue) {
|
foreach ($issues as $issue) {
|
||||||
$closedAt = $issue['closed_at'] ?? '';
|
$closedAt = $issue['closed_at'] ?? '';
|
||||||
$locked = $issue['locked'] ?? false;
|
$locked = $issue['locked'] ?? false;
|
||||||
$num = $issue['number'] ?? 0;
|
$num = $issue['number'] ?? 0;
|
||||||
|
|
||||||
if ($locked || $closedAt > $cutoff || $num === 0) continue;
|
if ($locked || $closedAt > $cutoff || $num === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->dryRun) {
|
if (!$this->dryRun) {
|
||||||
try {
|
try {
|
||||||
$this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [
|
$this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [
|
||||||
'lock_reason' => 'resolved',
|
'lock_reason' => 'resolved',
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) { continue; }
|
} catch (\Exception $e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$results['issues_locked']++;
|
$results['issues_locked']++;
|
||||||
$changed = true;
|
$changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($results['issues_locked'] > 0) {
|
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;
|
return $changed;
|
||||||
}
|
}
|
||||||
@@ -359,17 +363,21 @@ class RepoCleanup extends CLIApp
|
|||||||
try {
|
try {
|
||||||
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
|
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
|
||||||
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
|
$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)
|
// 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 (self::RETIRED_WORKFLOWS as $wf) {
|
||||||
foreach ($wfDirs as $wfDir) {
|
foreach ($wfDirs as $wfDir) {
|
||||||
$path = "{$wfDir}/{$wf}";
|
$path = "{$wfDir}/{$wf}";
|
||||||
try {
|
try {
|
||||||
$file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
|
$file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
|
||||||
$sha = $file['sha'] ?? '';
|
$sha = $file['sha'] ?? '';
|
||||||
if (empty($sha)) continue;
|
if (empty($sha)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->dryRun) {
|
if (!$this->dryRun) {
|
||||||
$this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [
|
$this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [
|
||||||
@@ -378,7 +386,7 @@ class RepoCleanup extends CLIApp
|
|||||||
'branch' => $defaultBranch,
|
'branch' => $defaultBranch,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$this->log(" Deleted retired: {$wf} (from {$wfDir})");
|
$this->logMsg(" Deleted retired: {$wf} (from {$wfDir})");
|
||||||
$results['retired_files']++;
|
$results['retired_files']++;
|
||||||
$changed = true;
|
$changed = true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -405,13 +413,17 @@ class RepoCleanup extends CLIApp
|
|||||||
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}");
|
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}");
|
||||||
$results['runs_deleted']++;
|
$results['runs_deleted']++;
|
||||||
$changed = true;
|
$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) {
|
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;
|
return $changed;
|
||||||
}
|
}
|
||||||
@@ -419,7 +431,7 @@ class RepoCleanup extends CLIApp
|
|||||||
private function cleanOldLogs(string $org, string $repo, array &$results): bool
|
private function cleanOldLogs(string $org, string $repo, array &$results): bool
|
||||||
{
|
{
|
||||||
$changed = false;
|
$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"));
|
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -433,13 +445,17 @@ class RepoCleanup extends CLIApp
|
|||||||
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs");
|
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs");
|
||||||
$results['logs_deleted']++;
|
$results['logs_deleted']++;
|
||||||
$changed = true;
|
$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) {
|
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;
|
return $changed;
|
||||||
}
|
}
|
||||||
@@ -449,7 +465,7 @@ class RepoCleanup extends CLIApp
|
|||||||
try {
|
try {
|
||||||
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log(" ⚠️ Missing 'mokostandards' label");
|
$this->logMsg(" ⚠️ Missing 'mokostandards' label");
|
||||||
$results['labels_missing']++;
|
$results['labels_missing']++;
|
||||||
$this->api->resetCircuitBreaker();
|
$this->api->resetCircuitBreaker();
|
||||||
}
|
}
|
||||||
@@ -469,7 +485,7 @@ class RepoCleanup extends CLIApp
|
|||||||
$mokoContent = base64_decode($mokoFile['content'] ?? '');
|
$mokoContent = base64_decode($mokoFile['content'] ?? '');
|
||||||
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
|
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
|
||||||
if ($vm[1] !== self::VERSION) {
|
if ($vm[1] !== self::VERSION) {
|
||||||
$this->log(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
|
$this->logMsg(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
|
||||||
$results['version_drift']++;
|
$results['version_drift']++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,14 +500,14 @@ class RepoCleanup extends CLIApp
|
|||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function log(string $message): void
|
private function logMsg(string $message): void
|
||||||
{
|
{
|
||||||
if (!$this->getOption('quiet', false)) {
|
if (!$this->quiet) {
|
||||||
echo $message . "\n";
|
echo $message . "\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function error(string $message): void
|
private function errorMsg(string $message): void
|
||||||
{
|
{
|
||||||
fwrite(STDERR, $message . "\n");
|
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 "$@"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
*
|
||||||
@@ -10,9 +11,8 @@
|
|||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: MokoStandards.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: MokoStandards
|
||||||
* REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /bin/moko
|
* PATH: /bin/moko
|
||||||
* VERSION: 04.00.15
|
|
||||||
* BRIEF: Unified CLI dispatcher — run any MokoStandards script without needing GitHub Actions
|
* BRIEF: Unified CLI dispatcher — run any MokoStandards script without needing GitHub Actions
|
||||||
*
|
*
|
||||||
* USAGE
|
* USAGE
|
||||||
@@ -69,6 +69,11 @@ declare(strict_types=1);
|
|||||||
$repoRoot = dirname(__DIR__);
|
$repoRoot = dirname(__DIR__);
|
||||||
$autoloader = $repoRoot . '/vendor/autoload.php';
|
$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)) {
|
if (!is_file($autoloader)) {
|
||||||
fwrite(STDERR, "Error: vendor/autoload.php not found.\nRun: composer install\n");
|
fwrite(STDERR, "Error: vendor/autoload.php not found.\nRun: composer install\n");
|
||||||
exit(2);
|
exit(2);
|
||||||
@@ -84,45 +89,87 @@ require_once $autoloader;
|
|||||||
*/
|
*/
|
||||||
const COMMAND_MAP = [
|
const COMMAND_MAP = [
|
||||||
// Automation
|
// Automation
|
||||||
'sync' => 'api/automation/bulk_sync.php',
|
'sync' => 'automation/bulk_sync.php',
|
||||||
|
|
||||||
// Maintenance
|
// Maintenance
|
||||||
'inventory' => 'api/maintenance/update_repo_inventory.php',
|
'inventory' => 'maintenance/update_repo_inventory.php',
|
||||||
|
|
||||||
// Validation — general
|
// Validation — general
|
||||||
'health' => 'api/validate/check_repo_health.php',
|
'health' => 'validate/check_repo_health.php',
|
||||||
'check:syntax' => 'api/validate/check_php_syntax.php',
|
'check:syntax' => 'validate/check_php_syntax.php',
|
||||||
'check:version' => 'api/validate/check_version_consistency.php',
|
'check:version' => 'validate/check_version_consistency.php',
|
||||||
'check:changelog' => 'api/validate/check_changelog.php',
|
'check:changelog' => 'validate/check_changelog.php',
|
||||||
'check:structure' => 'api/validate/check_structure.php',
|
'check:structure' => 'validate/check_structure.php',
|
||||||
'check:headers' => 'api/validate/check_license_headers.php',
|
'check:headers' => 'validate/check_license_headers.php',
|
||||||
'check:secrets' => 'api/validate/check_no_secrets.php',
|
'check:secrets' => 'validate/check_no_secrets.php',
|
||||||
'check:tabs' => 'api/validate/check_tabs.php',
|
'check:tabs' => 'validate/check_tabs.php',
|
||||||
'check:paths' => 'api/validate/check_paths.php',
|
'check:paths' => 'validate/check_paths.php',
|
||||||
'check:xml' => 'api/validate/check_xml_wellformed.php',
|
'check:xml' => 'validate/check_xml_wellformed.php',
|
||||||
'check:enterprise' => 'api/validate/check_enterprise_readiness.php',
|
'check:enterprise' => 'validate/check_enterprise_readiness.php',
|
||||||
|
|
||||||
// Validation — platform-specific
|
// Validation — platform-specific
|
||||||
'check:dolibarr' => 'api/validate/check_dolibarr_module.php',
|
'check:dolibarr' => 'validate/check_dolibarr_module.php',
|
||||||
'check:joomla' => 'api/validate/check_joomla_manifest.php',
|
'check:joomla' => 'validate/check_joomla_manifest.php',
|
||||||
'check:language' => 'api/validate/check_language_structure.php',
|
'check:language' => 'validate/check_language_structure.php',
|
||||||
|
'check:client' => 'validate/check_client_theme.php',
|
||||||
|
'check:wiki' => 'validate/check_wiki_health.php',
|
||||||
|
|
||||||
// Detection
|
// Detection
|
||||||
'detect' => 'api/validate/auto_detect_platform.php',
|
'detect' => 'validate/auto_detect_platform.php',
|
||||||
|
|
||||||
// Org-wide
|
// Org-wide
|
||||||
'drift' => 'api/validate/scan_drift.php',
|
'drift' => 'validate/scan_drift.php',
|
||||||
|
|
||||||
// Release
|
// Release
|
||||||
'release' => 'api/cli/release.php',
|
'release' => 'cli/release.php',
|
||||||
|
'release:notes' => 'cli/release_notes.php',
|
||||||
|
'release:validate' => 'cli/release_validate.php',
|
||||||
|
'manifest:element' => 'cli/manifest_element.php',
|
||||||
|
'release:cascade' => 'cli/release_cascade.php',
|
||||||
|
'release:promote' => 'cli/release_promote.php',
|
||||||
|
'release:create' => 'cli/release_create.php',
|
||||||
|
'release:manage' => 'cli/release_manage.php',
|
||||||
|
'release:mirror' => 'cli/release_mirror.php',
|
||||||
|
'release:package' => 'cli/release_package.php',
|
||||||
|
|
||||||
// CLI utilities (used by workflows — centralized logic)
|
// Changelog
|
||||||
'version:read' => 'api/cli/version_read.php',
|
'changelog:promote' => 'cli/changelog_promote.php',
|
||||||
'version:bump' => 'api/cli/version_bump.php',
|
'changelog:prune' => 'cli/changelog_prune.php',
|
||||||
'version:propagate' => 'api/maintenance/update_version_from_readme.php',
|
|
||||||
'version:set-platform' => 'api/cli/version_set_platform.php',
|
// Version management
|
||||||
'platform:detect' => 'api/cli/platform_detect.php',
|
'version:read' => 'cli/version_read.php',
|
||||||
'release:notes' => 'api/cli/release_notes.php',
|
'version:bump' => 'cli/version_bump.php',
|
||||||
|
'version:check' => 'cli/version_check.php',
|
||||||
|
'version:propagate' => 'maintenance/update_version_from_readme.php',
|
||||||
|
'version:set-platform' => 'cli/version_set_platform.php',
|
||||||
|
'version:reset-dev' => 'cli/version_reset_dev.php',
|
||||||
|
|
||||||
|
// 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',
|
'validate:module' => 'bin/validate-module',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -206,24 +253,112 @@ function printCommandList(): void
|
|||||||
{
|
{
|
||||||
echo "Available commands:\n\n";
|
echo "Available commands:\n\n";
|
||||||
|
|
||||||
$groups = [
|
// Auto-group by command prefix or comment-based sections
|
||||||
'Automation' => ['sync'],
|
$groups = [];
|
||||||
'Maintenance' => ['inventory'],
|
foreach (COMMAND_MAP as $cmd => $path) {
|
||||||
'Validation (general)' => ['health', 'check:syntax', 'check:version', 'check:changelog',
|
if (str_contains($cmd, ':')) {
|
||||||
'check:structure', 'check:headers', 'check:secrets',
|
$prefix = explode(':', $cmd)[0];
|
||||||
'check:tabs', 'check:paths', 'check:xml', 'check:enterprise'],
|
$groupName = match ($prefix) {
|
||||||
'Validation (platform)' => ['check:dolibarr', 'check:joomla', 'check:language', 'detect'],
|
'check' => 'Validation',
|
||||||
'Organisation-wide' => ['drift'],
|
'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) {
|
foreach ($groups as $group => $commands) {
|
||||||
echo " {$group}:\n";
|
echo " \033[1m{$group}\033[0m\n";
|
||||||
foreach ($commands as $cmd) {
|
ksort($commands);
|
||||||
printf(" %-22s %s\n", $cmd, COMMAND_MAP[$cmd]);
|
foreach ($commands as $cmd => $path) {
|
||||||
|
printf(" \033[36m%-26s\033[0m %s\n", $cmd, basename($path));
|
||||||
}
|
}
|
||||||
echo "\n";
|
echo "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Run: php bin/moko <command> --help for command-specific options.\n";
|
$total = count(COMMAND_MAP) + count($pluginCommands);
|
||||||
echo "All platforms: php bin/moko <command>\n";
|
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
-20
@@ -7,17 +7,16 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/archive_repo.php
|
* PATH: /cli/archive_repo.php
|
||||||
* VERSION: 04.06.10
|
|
||||||
* BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def
|
* BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def
|
||||||
*
|
*
|
||||||
* USAGE
|
* USAGE
|
||||||
* php api/cli/archive_repo.php --repo MokoOldModule
|
* php cli/archive_repo.php --repo MokoOldModule
|
||||||
* php api/cli/archive_repo.php --repo MokoOldModule --dry-run
|
* php 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 --skip-close # Archive only, keep issues open
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -119,19 +118,7 @@ if (!$dryRun) {
|
|||||||
echo " (dry-run) would archive {$org}/{$repoName}\n";
|
echo " (dry-run) would archive {$org}/{$repoName}\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5: Remove sync definition ──────────────────────────────────────
|
// ── Step 5: (removed — sync definitions no longer used) ─────────────────
|
||||||
echo "Step 5: Removing sync definition...\n";
|
|
||||||
$defFile = "{$repoRoot}/definitions/sync/{$repoName}.def.tf";
|
|
||||||
if (file_exists($defFile)) {
|
|
||||||
if (!$dryRun) {
|
|
||||||
unlink($defFile);
|
|
||||||
echo " Removed: {$defFile}\n";
|
|
||||||
} else {
|
|
||||||
echo " (dry-run) would remove {$defFile}\n";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo " No sync definition found\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 6: Create archival record ──────────────────────────────────────
|
// ── Step 6: Create archival record ──────────────────────────────────────
|
||||||
echo "Step 6: Creating archival record...\n";
|
echo "Step 6: Creating archival record...\n";
|
||||||
|
|||||||
@@ -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,134 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* FILE INFORMATION
|
||||||
|
* DEFGROUP: moko-platform.CLI
|
||||||
|
* INGROUP: moko-platform
|
||||||
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
* PATH: /cli/changelog_prune.php
|
||||||
|
* BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php changelog_prune.php --path /repo --keep 5
|
||||||
|
* php changelog_prune.php --path /repo --keep 3 --dry-run
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$path = '.';
|
||||||
|
$keep = 5;
|
||||||
|
$dryRun = false;
|
||||||
|
|
||||||
|
foreach ($argv as $i => $arg) {
|
||||||
|
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||||
|
if ($arg === '--keep' && isset($argv[$i + 1])) $keep = (int)$argv[$i + 1];
|
||||||
|
if ($arg === '--dry-run') $dryRun = true;
|
||||||
|
if ($arg === '--help') {
|
||||||
|
echo "changelog_prune — Keep [Unreleased] + last N versioned entries\n\n";
|
||||||
|
echo "Usage: php changelog_prune.php --path . --keep 5 [--dry-run]\n\n";
|
||||||
|
echo "Options:\n";
|
||||||
|
echo " --path Repository path (default: .)\n";
|
||||||
|
echo " --keep Number of versioned releases to keep (default: 5)\n";
|
||||||
|
echo " --dry-run Preview without writing\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
|
$lines = explode("\n", $content);
|
||||||
|
|
||||||
|
// Split into sections by ## headings
|
||||||
|
$sections = [];
|
||||||
|
$current = [];
|
||||||
|
$currentHeading = null;
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (preg_match('/^## /', $line)) {
|
||||||
|
if ($currentHeading !== null) {
|
||||||
|
$sections[] = ['heading' => $currentHeading, 'lines' => $current];
|
||||||
|
}
|
||||||
|
$currentHeading = $line;
|
||||||
|
$current = [$line];
|
||||||
|
} else {
|
||||||
|
$current[] = $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($currentHeading !== null) {
|
||||||
|
$sections[] = ['heading' => $currentHeading, 'lines' => $current];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the header (everything before the first ## section)
|
||||||
|
$header = [];
|
||||||
|
$contentLines = explode("\n", $content);
|
||||||
|
foreach ($contentLines as $line) {
|
||||||
|
if (preg_match('/^## /', $line)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$header[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate [Unreleased] from versioned sections
|
||||||
|
$unreleased = null;
|
||||||
|
$versioned = [];
|
||||||
|
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
if (preg_match('/\[Unreleased\]/i', $section['heading'])) {
|
||||||
|
$unreleased = $section;
|
||||||
|
} else {
|
||||||
|
$versioned[] = $section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalVersioned = count($versioned);
|
||||||
|
$pruned = $totalVersioned - $keep;
|
||||||
|
|
||||||
|
if ($pruned <= 0) {
|
||||||
|
echo "CHANGELOG has {$totalVersioned} versioned entries — nothing to prune (keeping {$keep})\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the first N versioned sections
|
||||||
|
$keptVersioned = array_slice($versioned, 0, $keep);
|
||||||
|
$droppedVersioned = array_slice($versioned, $keep);
|
||||||
|
|
||||||
|
// Report
|
||||||
|
echo "CHANGELOG: {$totalVersioned} versioned entries found\n";
|
||||||
|
echo " Keeping: {$keep} most recent\n";
|
||||||
|
echo " Pruning: {$pruned} old entries\n";
|
||||||
|
|
||||||
|
foreach ($droppedVersioned as $section) {
|
||||||
|
$heading = trim($section['heading']);
|
||||||
|
echo " - {$heading}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
echo "\n(dry-run) No changes written\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the file
|
||||||
|
$output = implode("\n", $header);
|
||||||
|
|
||||||
|
if ($unreleased !== null) {
|
||||||
|
$output .= implode("\n", $unreleased['lines']) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($keptVersioned as $section) {
|
||||||
|
$output .= implode("\n", $section['lines']) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up excessive blank lines at end
|
||||||
|
$output = rtrim($output) . "\n";
|
||||||
|
|
||||||
|
file_put_contents($changelog, $output);
|
||||||
|
echo "\nCHANGELOG pruned: removed {$pruned} old entries\n";
|
||||||
|
exit(0);
|
||||||
@@ -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
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/create_project.php
|
* PATH: /cli/create_project.php
|
||||||
* VERSION: 04.06.00
|
|
||||||
* BRIEF: Create baseline GitHub Projects for repositories with standard fields and views
|
* BRIEF: Create baseline GitHub Projects for repositories with standard fields and views
|
||||||
*
|
*
|
||||||
* USAGE
|
* USAGE
|
||||||
* php api/cli/create_project.php --repo MokoCRM # Auto-detect type, create project
|
* php cli/create_project.php --repo MokoCRM # Auto-detect type, create project
|
||||||
* php api/cli/create_project.php --repo MokoCRM --type dolibarr # Force type
|
* php 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 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 --dry-run # Preview without changes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
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.
|
* 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
|
// 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);
|
$data = restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient);
|
||||||
if (!empty($data['content'])) {
|
if (!empty($data['content'])) {
|
||||||
$content = base64_decode($data['content']);
|
$content = base64_decode($data['content']);
|
||||||
@@ -385,7 +384,7 @@ function createProject(
|
|||||||
updateProjectV2(input: {
|
updateProjectV2(input: {
|
||||||
projectId: $projectId,
|
projectId: $projectId,
|
||||||
shortDescription: $shortDescription,
|
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 }
|
projectV2 { id }
|
||||||
}
|
}
|
||||||
@@ -448,7 +447,7 @@ foreach ($repos as $repo) {
|
|||||||
// Detect project type
|
// Detect project type
|
||||||
$type = $typeOverride;
|
$type = $typeOverride;
|
||||||
if (!$type) {
|
if (!$type) {
|
||||||
$platform = detectPlatform($org, $repo, $token);
|
$platform = detectRepoPlatform($org, $repo, $token);
|
||||||
$type = $PLATFORM_TO_TYPE[$platform] ?? 'generic';
|
$type = $PLATFORM_TO_TYPE[$platform] ?? 'generic';
|
||||||
echo " Platform: {$platform} → type: {$type}\n";
|
echo " Platform: {$platform} → type: {$type}\n";
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-11
@@ -7,17 +7,16 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/create_repo.php
|
* PATH: /cli/create_repo.php
|
||||||
* VERSION: 04.06.10
|
|
||||||
* BRIEF: Scaffold a new governed repository with full MokoStandards baseline
|
* BRIEF: Scaffold a new governed repository with full MokoStandards baseline
|
||||||
*
|
*
|
||||||
* USAGE
|
* USAGE
|
||||||
* php api/cli/create_repo.php --name MokoNewModule --type dolibarr --description "My new module"
|
* php cli/create_repo.php --name MokoNewModule --type dolibarr --description "My new module"
|
||||||
* php api/cli/create_repo.php --name MokoNewModule --type joomla --private
|
* php 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 generic --dry-run
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -159,10 +158,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
|
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
DEFGROUP: {$name}
|
DEFGROUP: {$name}
|
||||||
INGROUP: MokoStandards
|
INGROUP: moko-platform
|
||||||
REPO: {$repoUrl}
|
REPO: {$repoUrl}
|
||||||
PATH: /README.md
|
PATH: /README.md
|
||||||
VERSION: 01.00.00
|
|
||||||
BRIEF: {$description}
|
BRIEF: {$description}
|
||||||
-->
|
-->
|
||||||
|
|
||||||
@@ -229,7 +227,7 @@ if (!$dryRun) {
|
|||||||
if (file_exists($syncScript)) {
|
if (file_exists($syncScript)) {
|
||||||
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
|
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
echo " (dry-run) would run initial sync\n";
|
echo " (dry-run) would run initial sync\n";
|
||||||
@@ -242,7 +240,7 @@ if (!$dryRun) {
|
|||||||
if (file_exists($projectScript)) {
|
if (file_exists($projectScript)) {
|
||||||
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
|
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
echo " (dry-run) would create Project\n";
|
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);
|
||||||
+118
-17
@@ -7,27 +7,34 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/joomla_release.php
|
* PATH: /cli/joomla_release.php
|
||||||
* VERSION: 04.06.00
|
|
||||||
* BRIEF: Joomla release pipeline — build ZIP+tar.gz, upload to GitHub Release, update updates.xml
|
* BRIEF: Joomla release pipeline — build ZIP+tar.gz, upload to GitHub Release, update updates.xml
|
||||||
*
|
*
|
||||||
* USAGE
|
* USAGE
|
||||||
* php api/cli/joomla_release.php --repo MokoCassiopeia --stability stable
|
* php cli/joomla_release.php --repo MokoCassiopeia --stability stable
|
||||||
* php api/cli/joomla_release.php --repo MokoCassiopeia --stability development
|
* php cli/joomla_release.php --repo MokoCassiopeia --stability development
|
||||||
* php api/cli/joomla_release.php --repo MokoCassiopeia --stability rc --dry-run
|
* php 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 --path /local/repo --stability stable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config, PlatformAdapterFactory};
|
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
|
||||||
|
|
||||||
class JoomlaRelease extends CLIApp
|
/**
|
||||||
|
* Joomla Release Manager
|
||||||
|
*
|
||||||
|
* Creates and manages Joomla extension releases on Gitea, including
|
||||||
|
* package building, asset upload, and update stream management.
|
||||||
|
*
|
||||||
|
* @since 04.06.00
|
||||||
|
*/
|
||||||
|
class JoomlaRelease extends CliFramework
|
||||||
{
|
{
|
||||||
private const VERSION = '04.06.00';
|
private const VERSION = '04.06.00';
|
||||||
private const ORG = 'mokoconsulting-tech';
|
private const ORG = 'mokoconsulting-tech';
|
||||||
@@ -49,7 +56,7 @@ class JoomlaRelease extends CLIApp
|
|||||||
];
|
];
|
||||||
|
|
||||||
private ApiClient $api;
|
private ApiClient $api;
|
||||||
private AuditLogger $logger;
|
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
@@ -76,7 +83,6 @@ class JoomlaRelease extends CLIApp
|
|||||||
$config = Config::load();
|
$config = Config::load();
|
||||||
$this->adapter = PlatformAdapterFactory::create($config);
|
$this->adapter = PlatformAdapterFactory::create($config);
|
||||||
$this->api = $this->adapter->getApiClient();
|
$this->api = $this->adapter->getApiClient();
|
||||||
$this->logger = new AuditLogger('joomla_release');
|
|
||||||
|
|
||||||
if ($repo !== '') {
|
if ($repo !== '') {
|
||||||
$path = $this->cloneRepo($repo);
|
$path = $this->cloneRepo($repo);
|
||||||
@@ -118,14 +124,21 @@ class JoomlaRelease extends CLIApp
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$zipName = "{$meta['element']}-{$displayVersion}.zip";
|
$prefix = $this->typePrefix($meta);
|
||||||
$tarName = "{$meta['element']}-{$displayVersion}.tar.gz";
|
$zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip";
|
||||||
|
$tarName = "{$prefix}{$meta['element']}-{$displayVersion}.tar.gz";
|
||||||
$zipPath = sys_get_temp_dir() . "/{$zipName}";
|
$zipPath = sys_get_temp_dir() . "/{$zipName}";
|
||||||
$tarPath = sys_get_temp_dir() . "/{$tarName}";
|
$tarPath = sys_get_temp_dir() . "/{$tarName}";
|
||||||
|
|
||||||
|
$this->log('INFO', "Type: {$meta['type']} | Element: {$meta['element']} | Group: {$meta['group']}");
|
||||||
|
|
||||||
$sha256 = 'dry-run';
|
$sha256 = 'dry-run';
|
||||||
if (!$dryRun) {
|
if (!$dryRun) {
|
||||||
$this->buildZip($srcDir, $zipPath);
|
if ($meta['type'] === 'package') {
|
||||||
|
$this->buildPackageZip($srcDir, $zipPath);
|
||||||
|
} else {
|
||||||
|
$this->buildZip($srcDir, $zipPath);
|
||||||
|
}
|
||||||
$this->buildTarGz($srcDir, $tarPath);
|
$this->buildTarGz($srcDir, $tarPath);
|
||||||
$sha256 = hash_file('sha256', $zipPath);
|
$sha256 = hash_file('sha256', $zipPath);
|
||||||
$this->log('SUCCESS', "ZIP: {$zipName} (" . filesize($zipPath) . " bytes)");
|
$this->log('SUCCESS', "ZIP: {$zipName} (" . filesize($zipPath) . " bytes)");
|
||||||
@@ -228,6 +241,94 @@ class JoomlaRelease extends CLIApp
|
|||||||
|
|
||||||
// ── Package building ─────────────────────────────────────────────
|
// ── 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
|
private function buildZip(string $srcDir, string $outPath): void
|
||||||
{
|
{
|
||||||
$zip = new \ZipArchive();
|
$zip = new \ZipArchive();
|
||||||
@@ -404,5 +505,5 @@ class JoomlaRelease extends CLIApp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$script = new JoomlaRelease('joomla_release', 'Joomla release pipeline');
|
$app = new JoomlaRelease();
|
||||||
exit($script->execute());
|
exit($app->execute());
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* FILE INFORMATION
|
||||||
|
* DEFGROUP: moko-platform.CLI
|
||||||
|
* INGROUP: moko-platform
|
||||||
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
* PATH: /cli/manifest_element.php
|
||||||
|
* BRIEF: Extract element name, type, type prefix, and ZIP name from manifest
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php manifest_element.php --path .
|
||||||
|
* php manifest_element.php --path . --version 09.01.00 --stability dev --github-output
|
||||||
|
*
|
||||||
|
* Detects platform (joomla, dolibarr, generic) and resolves:
|
||||||
|
* ext_element — canonical element name (e.g. mokojgdpc)
|
||||||
|
* ext_type — extension type (plugin, module, component, package, etc.)
|
||||||
|
* ext_folder — group/folder for plugins (e.g. system)
|
||||||
|
* ext_name — human-readable name (e.g. "Moko JGDPC")
|
||||||
|
* type_prefix — Joomla type prefix (plg_system_, com_, mod_, etc.)
|
||||||
|
* zip_name — computed ZIP filename
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$path = '.';
|
||||||
|
$version = null;
|
||||||
|
$stability = 'stable';
|
||||||
|
$githubOutput = false;
|
||||||
|
$repoName = '';
|
||||||
|
|
||||||
|
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 === '--repo' && isset($argv[$i + 1])) {
|
||||||
|
$repoName = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--github-output') {
|
||||||
|
$githubOutput = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = realpath($path) ?: $path;
|
||||||
|
|
||||||
|
// ── Detect platform from manifest.xml ────────────────────────────────────────
|
||||||
|
$platform = 'generic';
|
||||||
|
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||||
|
if (file_exists($manifestXml)) {
|
||||||
|
$content = file_get_contents($manifestXml);
|
||||||
|
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
|
||||||
|
$platform = trim($pm[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find extension manifest (Joomla XML) ─────────────────────────────────────
|
||||||
|
$extManifest = null;
|
||||||
|
$manifestFiles = array_merge(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
foreach ($manifestFiles as $file) {
|
||||||
|
$c = file_get_contents($file);
|
||||||
|
if (strpos($c, '<extension') !== false) {
|
||||||
|
$extManifest = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find Dolibarr module file ────────────────────────────────────────────────
|
||||||
|
$modFile = null;
|
||||||
|
$modFiles = array_merge(
|
||||||
|
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||||
|
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||||
|
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||||
|
);
|
||||||
|
foreach ($modFiles as $file) {
|
||||||
|
$c = file_get_contents($file);
|
||||||
|
if (strpos($c, 'extends DolibarrModules') !== false) {
|
||||||
|
$modFile = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extract metadata ─────────────────────────────────────────────────────────
|
||||||
|
$extElement = '';
|
||||||
|
$extType = '';
|
||||||
|
$extFolder = '';
|
||||||
|
$extName = '';
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
// Joomla platforms
|
||||||
|
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||||
|
$xml = file_get_contents($extManifest);
|
||||||
|
|
||||||
|
// Extension type and folder
|
||||||
|
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||||
|
$extType = $tm[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||||
|
$extFolder = $gm[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element name: <element>, plugin= attribute, <packagename>, or filename
|
||||||
|
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||||
|
$extElement = $em[1];
|
||||||
|
}
|
||||||
|
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
|
||||||
|
$extElement = $pm[1];
|
||||||
|
}
|
||||||
|
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||||
|
$extElement = $pn[1];
|
||||||
|
}
|
||||||
|
if (empty($extElement)) {
|
||||||
|
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||||
|
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable name
|
||||||
|
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
|
||||||
|
$extName = trim($nm[1]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Dolibarr platforms
|
||||||
|
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
||||||
|
$extType = 'dolibarr-module';
|
||||||
|
$modBasename = basename($modFile, '.class.php');
|
||||||
|
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
|
||||||
|
|
||||||
|
$modContent = file_get_contents($modFile);
|
||||||
|
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
|
||||||
|
$extName = $nm[1];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Generic / fallback
|
||||||
|
default:
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||||
|
$extType = 'generic';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strip existing type prefix from element to prevent duplication ────────────
|
||||||
|
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
|
||||||
|
|
||||||
|
// ── Compute type prefix ──────────────────────────────────────────────────────
|
||||||
|
$typePrefix = '';
|
||||||
|
switch ($extType) {
|
||||||
|
case 'plugin':
|
||||||
|
$typePrefix = "plg_{$extFolder}_";
|
||||||
|
break;
|
||||||
|
case 'module':
|
||||||
|
$typePrefix = 'mod_';
|
||||||
|
break;
|
||||||
|
case 'component':
|
||||||
|
$typePrefix = 'com_';
|
||||||
|
break;
|
||||||
|
case 'template':
|
||||||
|
$typePrefix = 'tpl_';
|
||||||
|
break;
|
||||||
|
case 'library':
|
||||||
|
$typePrefix = 'lib_';
|
||||||
|
break;
|
||||||
|
case 'package':
|
||||||
|
$typePrefix = 'pkg_';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compute ZIP name ─────────────────────────────────────────────────────────
|
||||||
|
$suffixMap = [
|
||||||
|
'development' => '-dev',
|
||||||
|
'dev' => '-dev',
|
||||||
|
'alpha' => '-alpha',
|
||||||
|
'beta' => '-beta',
|
||||||
|
'rc' => '-rc',
|
||||||
|
'release-candidate' => '-rc',
|
||||||
|
'stable' => '',
|
||||||
|
];
|
||||||
|
$suffix = $suffixMap[$stability] ?? '';
|
||||||
|
$zipName = '';
|
||||||
|
if ($version !== null) {
|
||||||
|
$zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback name
|
||||||
|
if (empty($extName)) {
|
||||||
|
$extName = $repoName ?: basename($root);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Output ───────────────────────────────────────────────────────────────────
|
||||||
|
$outputs = [
|
||||||
|
'platform' => $platform,
|
||||||
|
'ext_element' => $extElement,
|
||||||
|
'ext_type' => $extType,
|
||||||
|
'ext_folder' => $extFolder,
|
||||||
|
'ext_name' => $extName,
|
||||||
|
'type_prefix' => $typePrefix,
|
||||||
|
'zip_name' => $zipName,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($githubOutput) {
|
||||||
|
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||||
|
$lines = [];
|
||||||
|
foreach ($outputs as $key => $value) {
|
||||||
|
$lines[] = "{$key}={$value}";
|
||||||
|
}
|
||||||
|
if ($ghOutput) {
|
||||||
|
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||||
|
} else {
|
||||||
|
// Fallback: echo ::set-output (legacy)
|
||||||
|
foreach ($outputs as $key => $value) {
|
||||||
|
echo "::set-output name={$key}::{$value}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($outputs as $key => $value) {
|
||||||
|
echo "{$key}={$value}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(0);
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#!/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"} ?? ''),
|
||||||
|
'version' => (string)($xml->identity->version ?? ''),
|
||||||
|
'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
|
||||||
|
'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
|
||||||
|
'excludes' => (string)($xml->deploy->excludes ?? ''),
|
||||||
|
'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''),
|
||||||
|
'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
|
||||||
|
'manifest-file' => $manifestFile,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip empty values for cleaner output
|
||||||
|
$fields = array_filter($fields, fn($v) => $v !== '');
|
||||||
|
|
||||||
|
// -- Output -------------------------------------------------------------------
|
||||||
|
switch ($mode) {
|
||||||
|
case 'field':
|
||||||
|
if ($field === null) {
|
||||||
|
fwrite(STDERR, "Usage: manifest_read.php --path <dir> --field <name>
|
||||||
|
");
|
||||||
|
fwrite(STDERR, " manifest_read.php --path <dir> --all
|
||||||
|
");
|
||||||
|
fwrite(STDERR, " manifest_read.php --path <dir> --json
|
||||||
|
");
|
||||||
|
fwrite(STDERR, " manifest_read.php --path <dir> --github-output
|
||||||
|
");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
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
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/platform_detect.php
|
* PATH: /cli/platform_detect.php
|
||||||
* VERSION: 04.06.00
|
|
||||||
* BRIEF: Detect platform from .mokostandards file — outputs platform string
|
* BRIEF: Detect platform from .mokostandards file — outputs platform string
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+10
-11
@@ -5,18 +5,17 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/release.php
|
* PATH: /cli/release.php
|
||||||
* VERSION: 04.06.00
|
|
||||||
* BRIEF: Automate the MokoStandards version branch release flow
|
* BRIEF: Automate the MokoStandards version branch release flow
|
||||||
*
|
*
|
||||||
* USAGE
|
* USAGE
|
||||||
* php api/cli/release.php # Release current version
|
* php cli/release.php # Release current version
|
||||||
* php api/cli/release.php --bump minor # Bump minor, then release
|
* php cli/release.php --bump minor # Bump minor, then release
|
||||||
* php api/cli/release.php --bump major # Bump major, then release
|
* php cli/release.php --bump major # Bump major, then release
|
||||||
* php api/cli/release.php --dry-run # Preview without changes
|
* php cli/release.php --dry-run # Preview without changes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -30,10 +29,10 @@ foreach ($argv as $i => $arg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$repoRoot = dirname(__DIR__, 2);
|
$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
|
// Check both workflow directories for the bulk-repo-sync workflow
|
||||||
$bulkSyncFile = file_exists("{$repoRoot}/.gitea/workflows/bulk-repo-sync.yml")
|
$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml")
|
||||||
? "{$repoRoot}/.gitea/workflows/bulk-repo-sync.yml"
|
? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml"
|
||||||
: "{$repoRoot}/.github/workflows/bulk-repo-sync.yml";
|
: "{$repoRoot}/.github/workflows/bulk-repo-sync.yml";
|
||||||
$cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template";
|
$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,208 @@
|
|||||||
|
#!/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
|
||||||
|
* php release_cascade.php --stability stable --version 09.01.00 --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
|
||||||
|
*
|
||||||
|
* When --version is given, also deletes releases on any channel whose version
|
||||||
|
* is lower than the specified version (prevents stale pre-releases lingering).
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$stability = null;
|
||||||
|
$token = null;
|
||||||
|
$apiBase = null;
|
||||||
|
$version = 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];
|
||||||
|
}
|
||||||
|
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||||
|
$version = $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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Version-aware cleanup: delete releases with lesser version numbers ───────
|
||||||
|
if ($version !== null) {
|
||||||
|
// Normalize version for comparison (strip any suffix)
|
||||||
|
$baseVersion = preg_replace('/-[a-z]+$/', '', $version);
|
||||||
|
|
||||||
|
// Check all channels (including ones not in the cascade map for this stability)
|
||||||
|
$allChannels = ['development', 'alpha', 'beta', 'release-candidate', 'stable'];
|
||||||
|
foreach ($allChannels as $tag) {
|
||||||
|
// Skip the current stability channel
|
||||||
|
if ($tag === $stability) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip channels already deleted by cascade above
|
||||||
|
if (in_array($tag, $tagsToDelete, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
$releaseName = $data['name'] ?? '';
|
||||||
|
if ($releaseId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract version from release name (e.g. "element 09.00.01 (development)")
|
||||||
|
$releaseVersion = null;
|
||||||
|
if (preg_match('/(\d{2}\.\d{2}\.\d{2})/', $releaseName, $vm)) {
|
||||||
|
$releaseVersion = $vm[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($releaseVersion === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete if release version is less than the promoted version
|
||||||
|
if (version_compare($releaseVersion, $baseVersion, '<')) {
|
||||||
|
$delCh = curl_init("{$apiBase}/releases/{$releaseId}");
|
||||||
|
curl_setopt_array($delCh, [
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
curl_exec($delCh);
|
||||||
|
curl_close($delCh);
|
||||||
|
|
||||||
|
$tagCh = curl_init("{$apiBase}/tags/{$tag}");
|
||||||
|
curl_setopt_array($tagCh, [
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
curl_exec($tagCh);
|
||||||
|
curl_close($tagCh);
|
||||||
|
|
||||||
|
echo "Deleted: {$tag} — version {$releaseVersion} < {$baseVersion}\n";
|
||||||
|
$deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Cleaned up {$deleted} pre-release channel(s)\n";
|
||||||
|
exit(0);
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* FILE INFORMATION
|
||||||
|
* DEFGROUP: moko-platform.CLI
|
||||||
|
* INGROUP: moko-platform
|
||||||
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
* PATH: /cli/release_create.php
|
||||||
|
* BRIEF: Create or overwrite a Gitea release with proper naming
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL
|
||||||
|
* php release_create.php --version 09.01.00 --tag development --token TOKEN --api-base URL --prerelease
|
||||||
|
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL --path . --repo MyRepo
|
||||||
|
*
|
||||||
|
* Replaces the inline bash in auto-release.yml Step 7b.
|
||||||
|
* Detects extension metadata from manifest, builds a proper release name,
|
||||||
|
* generates release notes, and creates (or overwrites) a Gitea release.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// ── Argument parsing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$path = '.';
|
||||||
|
$version = null;
|
||||||
|
$tag = null;
|
||||||
|
$token = null;
|
||||||
|
$apiBase = null;
|
||||||
|
$branch = 'main';
|
||||||
|
$repoName = '';
|
||||||
|
$prerelease = 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 === '--tag' && isset($argv[$i + 1])) {
|
||||||
|
$tag = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||||
|
$token = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||||
|
$apiBase = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||||
|
$branch = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||||
|
$repoName = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--prerelease') {
|
||||||
|
$prerelease = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow token from environment
|
||||||
|
if ($token === null) {
|
||||||
|
$envToken = getenv('GA_TOKEN');
|
||||||
|
if ($envToken === false || $envToken === '') {
|
||||||
|
$envToken = getenv('GITEA_TOKEN');
|
||||||
|
}
|
||||||
|
if ($envToken !== false && $envToken !== '') {
|
||||||
|
$token = $envToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($version === null || $tag === null || $token === null || $apiBase === null) {
|
||||||
|
fwrite(STDERR, "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]\n");
|
||||||
|
fwrite(STDERR, " --path . Repo root for manifest detection (default: .)\n");
|
||||||
|
fwrite(STDERR, " --branch main Target commitish (default: main)\n");
|
||||||
|
fwrite(STDERR, " --repo REPO Repo name for fallback element detection\n");
|
||||||
|
fwrite(STDERR, " --prerelease Mark release as prerelease\n");
|
||||||
|
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: Gitea API request ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a request to the Gitea API.
|
||||||
|
*
|
||||||
|
* @param string $url Full API URL
|
||||||
|
* @param string $token Authorization token
|
||||||
|
* @param string $method HTTP method (GET, POST, DELETE, etc.)
|
||||||
|
* @param string|null $body JSON request body
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null Decoded response or null on failure
|
||||||
|
*/
|
||||||
|
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: token {$token}",
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
]);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($response, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detect element metadata ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$root = realpath($path) ?: $path;
|
||||||
|
|
||||||
|
$extElement = '';
|
||||||
|
$extType = '';
|
||||||
|
$extFolder = '';
|
||||||
|
$extName = '';
|
||||||
|
$typePrefix = '';
|
||||||
|
|
||||||
|
// Detect platform from manifest.xml
|
||||||
|
$platform = 'generic';
|
||||||
|
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||||
|
if (file_exists($manifestXml)) {
|
||||||
|
$content = file_get_contents($manifestXml);
|
||||||
|
if ($content !== false && preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
|
||||||
|
$platform = trim($pm[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find extension manifest (Joomla XML)
|
||||||
|
$extManifest = null;
|
||||||
|
$manifestFiles = array_merge(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
foreach ($manifestFiles as $file) {
|
||||||
|
$c = file_get_contents($file);
|
||||||
|
if ($c !== false && strpos($c, '<extension') !== false) {
|
||||||
|
$extManifest = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find Dolibarr module file
|
||||||
|
$modFile = null;
|
||||||
|
$modFiles = array_merge(
|
||||||
|
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||||
|
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||||
|
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||||
|
);
|
||||||
|
foreach ($modFiles as $file) {
|
||||||
|
$c = file_get_contents($file);
|
||||||
|
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
|
||||||
|
$modFile = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata based on platform
|
||||||
|
switch (true) {
|
||||||
|
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||||
|
$xml = file_get_contents($extManifest);
|
||||||
|
if ($xml === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||||
|
$extType = $tm[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||||
|
$extFolder = $gm[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element name: <element>, plugin= attribute, <packagename>, or filename
|
||||||
|
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||||
|
$extElement = $em[1];
|
||||||
|
}
|
||||||
|
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
|
||||||
|
$extElement = $pm2[1];
|
||||||
|
}
|
||||||
|
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||||
|
$extElement = $pn[1];
|
||||||
|
}
|
||||||
|
if (empty($extElement)) {
|
||||||
|
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||||
|
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable name
|
||||||
|
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
|
||||||
|
$extName = trim($nm[1]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
||||||
|
$extType = 'dolibarr-module';
|
||||||
|
$modBasename = basename($modFile, '.class.php');
|
||||||
|
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename);
|
||||||
|
|
||||||
|
$modContent = file_get_contents($modFile);
|
||||||
|
if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) {
|
||||||
|
$extName = $nm2[1];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||||
|
$extType = 'generic';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip existing type prefix from element to prevent duplication
|
||||||
|
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement;
|
||||||
|
|
||||||
|
// Compute type prefix
|
||||||
|
switch ($extType) {
|
||||||
|
case 'plugin':
|
||||||
|
$typePrefix = "plg_{$extFolder}_";
|
||||||
|
break;
|
||||||
|
case 'module':
|
||||||
|
$typePrefix = 'mod_';
|
||||||
|
break;
|
||||||
|
case 'component':
|
||||||
|
$typePrefix = 'com_';
|
||||||
|
break;
|
||||||
|
case 'template':
|
||||||
|
$typePrefix = 'tpl_';
|
||||||
|
break;
|
||||||
|
case 'library':
|
||||||
|
$typePrefix = 'lib_';
|
||||||
|
break;
|
||||||
|
case 'package':
|
||||||
|
$typePrefix = 'pkg_';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback name
|
||||||
|
if (empty($extName)) {
|
||||||
|
$extName = $repoName !== '' ? $repoName : basename($root);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n";
|
||||||
|
|
||||||
|
// ── Build release name ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$releaseName = "{$extName} {$version} ({$typePrefix}{$extElement}-{$version})";
|
||||||
|
echo "Release name: {$releaseName}\n";
|
||||||
|
|
||||||
|
// ── Generate release notes ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$releaseNotes = "Release {$version}";
|
||||||
|
$releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php';
|
||||||
|
if (file_exists($releaseNotesScript)) {
|
||||||
|
$cmd = sprintf(
|
||||||
|
'php %s --path %s --version %s',
|
||||||
|
escapeshellarg($releaseNotesScript),
|
||||||
|
escapeshellarg($root),
|
||||||
|
escapeshellarg($version)
|
||||||
|
);
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd, $output, $exitCode);
|
||||||
|
if ($exitCode === 0 && count($output) > 0) {
|
||||||
|
$notes = implode("\n", $output);
|
||||||
|
if (trim($notes) !== '') {
|
||||||
|
$releaseNotes = $notes;
|
||||||
|
echo "Release notes: generated from CHANGELOG.md\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete existing release at tag (if present) ─────────────────────────────
|
||||||
|
|
||||||
|
$existing = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
|
||||||
|
if ($existing !== null && !empty($existing['id'])) {
|
||||||
|
$existingId = $existing['id'];
|
||||||
|
echo "Deleting existing release: {$tag} (id: {$existingId})\n";
|
||||||
|
|
||||||
|
// Delete release
|
||||||
|
giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE');
|
||||||
|
|
||||||
|
// Delete tag
|
||||||
|
giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create new release ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'tag_name' => $tag,
|
||||||
|
'target_commitish' => $branch,
|
||||||
|
'name' => $releaseName,
|
||||||
|
'body' => $releaseNotes,
|
||||||
|
'prerelease' => $prerelease,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}');
|
||||||
|
if ($newRelease === null || empty($newRelease['id'])) {
|
||||||
|
fwrite(STDERR, "Failed to create release at tag: {$tag}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseId = $newRelease['id'];
|
||||||
|
echo "Created release: {$tag} (id: {$releaseId})\n";
|
||||||
|
|
||||||
|
// Output release_id to stdout for CI consumption
|
||||||
|
echo "release_id={$releaseId}\n";
|
||||||
|
exit(0);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* FILE INFORMATION
|
||||||
|
* DEFGROUP: moko-platform.CLI
|
||||||
|
* INGROUP: moko-platform
|
||||||
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
* PATH: /cli/release_mirror.php
|
||||||
|
* BRIEF: Mirror a Gitea release (with assets) to a GitHub repository
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php release_mirror.php --version 09.01.00 --tag stable --token TOKEN --api-base URL \
|
||||||
|
* --gh-token GH_TOKEN --gh-repo MokoConsulting/MokoWaaS
|
||||||
|
*
|
||||||
|
* Mirrors a Gitea release (title, body, assets) to a corresponding GitHub release.
|
||||||
|
* If the GitHub release already exists at the same tag, its title is updated via PATCH.
|
||||||
|
* All assets from the Gitea release are downloaded and uploaded to the GitHub release.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// ── Argument parsing ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$version = null;
|
||||||
|
$tag = null;
|
||||||
|
$token = null;
|
||||||
|
$apiBase = null;
|
||||||
|
$ghToken = null;
|
||||||
|
$ghRepo = null;
|
||||||
|
$branch = 'main';
|
||||||
|
|
||||||
|
foreach ($argv as $i => $arg) {
|
||||||
|
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||||
|
$version = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--tag' && isset($argv[$i + 1])) {
|
||||||
|
$tag = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||||
|
$token = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||||
|
$apiBase = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--gh-token' && isset($argv[$i + 1])) {
|
||||||
|
$ghToken = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--gh-repo' && isset($argv[$i + 1])) {
|
||||||
|
$ghRepo = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||||
|
$branch = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow tokens from environment
|
||||||
|
$token = $token ?: (getenv('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
|
||||||
|
$ghToken = $ghToken ?: (getenv('GH_TOKEN') ?: null);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$version === null || $tag === null || $token === null || $apiBase === null
|
||||||
|
|| $ghToken === null || $ghRepo === null
|
||||||
|
) {
|
||||||
|
fwrite(STDERR, "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " .
|
||||||
|
"--api-base URL --gh-token GH_TOKEN --gh-repo org/repo [--branch main]\n");
|
||||||
|
fwrite(STDERR, " --token: Gitea token (or GA_TOKEN / GITEA_TOKEN env)\n");
|
||||||
|
fwrite(STDERR, " --gh-token: GitHub token (or GH_TOKEN env)\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: Gitea API request ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a request to the Gitea API.
|
||||||
|
*
|
||||||
|
* @param string $url Full Gitea API URL
|
||||||
|
* @param string $token Gitea API token
|
||||||
|
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
|
||||||
|
* @param string|null $body JSON request body or null
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null Decoded response or null on failure
|
||||||
|
*/
|
||||||
|
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: token {$token}",
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
]);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return json_decode($response, true) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from Gitea to a local path.
|
||||||
|
*
|
||||||
|
* @param string $url Download URL
|
||||||
|
* @param string $token Gitea API token
|
||||||
|
* @param string $dest Local destination path
|
||||||
|
*
|
||||||
|
* @return bool True on success
|
||||||
|
*/
|
||||||
|
function giteaDownload(string $url, string $token, string $dest): bool
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$fp = fopen($dest, 'wb');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||||
|
CURLOPT_FILE => $fp,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
fclose($fp);
|
||||||
|
return $httpCode >= 200 && $httpCode < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a request to the GitHub API.
|
||||||
|
*
|
||||||
|
* @param string $url Full GitHub API URL
|
||||||
|
* @param string $token GitHub personal access token
|
||||||
|
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
|
||||||
|
* @param string|null $body JSON request body or null
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null Decoded response or null on failure
|
||||||
|
*/
|
||||||
|
function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: token {$token}",
|
||||||
|
'Accept: application/vnd.github+json',
|
||||||
|
'User-Agent: moko-platform',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
]);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return json_decode($response, true) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a binary asset to a GitHub release.
|
||||||
|
*
|
||||||
|
* @param string $uploadUrl GitHub upload URL (uploads.github.com)
|
||||||
|
* @param string $token GitHub personal access token
|
||||||
|
* @param string $filePath Local file path to upload
|
||||||
|
* @param string $name Asset filename for GitHub
|
||||||
|
*
|
||||||
|
* @return int HTTP status code
|
||||||
|
*/
|
||||||
|
function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int
|
||||||
|
{
|
||||||
|
$url = $uploadUrl . '?name=' . urlencode($name);
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: token {$token}",
|
||||||
|
'Accept: application/vnd.github+json',
|
||||||
|
'User-Agent: moko-platform',
|
||||||
|
'Content-Type: application/octet-stream',
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => file_get_contents($filePath),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
return $httpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: Get Gitea release by tag ─────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "Fetching Gitea release: {$tag}\n";
|
||||||
|
$giteaRelease = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
|
||||||
|
if (!$giteaRelease || empty($giteaRelease['id'])) {
|
||||||
|
fwrite(STDERR, "No Gitea release found with tag: {$tag}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$giteaId = $giteaRelease['id'];
|
||||||
|
$releaseName = $giteaRelease['name'] ?? "{$version}";
|
||||||
|
$releaseBody = $giteaRelease['body'] ?? '';
|
||||||
|
$assets = $giteaRelease['assets'] ?? [];
|
||||||
|
|
||||||
|
echo " Name: {$releaseName}\n";
|
||||||
|
echo " Assets: " . count($assets) . " file(s)\n";
|
||||||
|
|
||||||
|
// ── Step 2: Check / create GitHub release ────────────────────────────────────
|
||||||
|
|
||||||
|
$ghApiBase = "https://api.github.com/repos/{$ghRepo}";
|
||||||
|
$ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}";
|
||||||
|
|
||||||
|
echo "Checking GitHub release: {$tag}\n";
|
||||||
|
$ghRelease = githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken);
|
||||||
|
|
||||||
|
if ($ghRelease && !empty($ghRelease['id'])) {
|
||||||
|
// Update existing release title
|
||||||
|
$ghReleaseId = $ghRelease['id'];
|
||||||
|
echo " GitHub release exists (id: {$ghReleaseId}), updating title\n";
|
||||||
|
$patchPayload = json_encode([
|
||||||
|
'name' => $releaseName,
|
||||||
|
'body' => $releaseBody,
|
||||||
|
]);
|
||||||
|
githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload);
|
||||||
|
} else {
|
||||||
|
// Create new release
|
||||||
|
echo " Creating GitHub release\n";
|
||||||
|
$createPayload = json_encode([
|
||||||
|
'tag_name' => $tag,
|
||||||
|
'target_commitish' => $branch,
|
||||||
|
'name' => $releaseName,
|
||||||
|
'body' => $releaseBody,
|
||||||
|
'draft' => false,
|
||||||
|
'prerelease' => ($tag !== 'stable'),
|
||||||
|
]);
|
||||||
|
$ghRelease = githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload);
|
||||||
|
if (!$ghRelease || empty($ghRelease['id'])) {
|
||||||
|
fwrite(STDERR, "Failed to create GitHub release\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$ghReleaseId = $ghRelease['id'];
|
||||||
|
echo " Created GitHub release (id: {$ghReleaseId})\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: Download assets from Gitea ───────────────────────────────────────
|
||||||
|
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid();
|
||||||
|
@mkdir($tmpDir, 0755, true);
|
||||||
|
|
||||||
|
$uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets";
|
||||||
|
|
||||||
|
foreach ($assets as $asset) {
|
||||||
|
$name = $asset['name'] ?? '';
|
||||||
|
$downloadUrl = $asset['browser_download_url'] ?? '';
|
||||||
|
if ($name === '' || $downloadUrl === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$localPath = "{$tmpDir}/{$name}";
|
||||||
|
echo " Downloading: {$name}\n";
|
||||||
|
|
||||||
|
if (!giteaDownload($downloadUrl, $token, $localPath)) {
|
||||||
|
fwrite(STDERR, " Failed to download: {$name}\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: Upload asset to GitHub ───────────────────────────────────────
|
||||||
|
echo " Uploading: {$name}\n";
|
||||||
|
$code = githubUploadAsset($uploadUrl, $ghToken, $localPath, $name);
|
||||||
|
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
|
||||||
|
echo " {$status}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
array_map('unlink', glob("{$tmpDir}/*") ?: []);
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
|
||||||
|
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n";
|
||||||
|
echo " Version: {$version}\n";
|
||||||
|
echo " Assets: " . count($assets) . " file(s)\n";
|
||||||
|
exit(0);
|
||||||
@@ -5,11 +5,10 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/release_notes.php
|
* PATH: /cli/release_notes.php
|
||||||
* VERSION: 04.06.00
|
|
||||||
* BRIEF: Extract release notes from CHANGELOG.md for a given version
|
* BRIEF: Extract release notes from CHANGELOG.md for a given version
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* FILE INFORMATION
|
||||||
|
* DEFGROUP: moko-platform.CLI
|
||||||
|
* INGROUP: moko-platform
|
||||||
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
* PATH: /cli/release_package.php
|
||||||
|
* BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php release_package.php --path . --version 09.01.00 --tag stable --token TOKEN --api-base URL
|
||||||
|
* php release_package.php --path . --version 09.01.00 --tag development --token TOKEN --api-base URL --repo myrepo
|
||||||
|
*
|
||||||
|
* Builds ZIP and tar.gz packages from src/ or htdocs/, computes SHA-256 checksums,
|
||||||
|
* creates .sha256 sidecar files, and uploads all assets to an existing Gitea release.
|
||||||
|
*
|
||||||
|
* For Joomla packages (type=package with packages/ subdir):
|
||||||
|
* - ZIPs each sub-extension directory
|
||||||
|
* - Copies top-level XML/PHP to package root before archiving
|
||||||
|
*
|
||||||
|
* For standard extensions:
|
||||||
|
* - Builds ZIP and tar.gz from source dir
|
||||||
|
* - Excludes: sftp-config*, .ftpignore, *.ppk, *.pem, *.key, .env*, *.local, .build-trigger
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// ── Argument parsing ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$path = '.';
|
||||||
|
$version = null;
|
||||||
|
$tag = null;
|
||||||
|
$token = null;
|
||||||
|
$apiBase = null;
|
||||||
|
$repoName = '';
|
||||||
|
$outputDir = sys_get_temp_dir();
|
||||||
|
|
||||||
|
foreach ($argv as $i => $arg) {
|
||||||
|
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||||
|
$path = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||||
|
$version = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--tag' && isset($argv[$i + 1])) {
|
||||||
|
$tag = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||||
|
$token = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||||
|
$apiBase = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||||
|
$repoName = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--output' && isset($argv[$i + 1])) {
|
||||||
|
$outputDir = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow token from environment
|
||||||
|
if ($token === null) {
|
||||||
|
$token = getenv('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($version === null || $tag === null || $token === null || $apiBase === null) {
|
||||||
|
fwrite(STDERR, "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL\n");
|
||||||
|
fwrite(STDERR, " --repo REPO Repo name for element detection fallback\n");
|
||||||
|
fwrite(STDERR, " --output DIR Output directory for built packages (default: sys_get_temp_dir())\n");
|
||||||
|
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = realpath($path) ?: $path;
|
||||||
|
|
||||||
|
// ── Helper: Gitea API request ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a Gitea API request.
|
||||||
|
*
|
||||||
|
* @param string $url Full API URL
|
||||||
|
* @param string $token API token
|
||||||
|
* @param string $method HTTP method
|
||||||
|
* @param string|null $body Request body (JSON)
|
||||||
|
*
|
||||||
|
* @return array{data: array<string, mixed>|null, code: int}
|
||||||
|
*/
|
||||||
|
function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
return ['data' => null, 'code' => 0];
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: token {$token}",
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
]);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') {
|
||||||
|
return ['data' => null, 'code' => $httpCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($response, true);
|
||||||
|
return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file as a release asset.
|
||||||
|
*
|
||||||
|
* @param string $url Upload endpoint URL
|
||||||
|
* @param string $token API token
|
||||||
|
* @param string $filePath Local file path
|
||||||
|
*
|
||||||
|
* @return int HTTP status code
|
||||||
|
*/
|
||||||
|
function giteaUploadAsset(string $url, string $token, string $filePath): int
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$fileContent = file_get_contents($filePath);
|
||||||
|
if ($fileContent === false) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: token {$token}",
|
||||||
|
'Content-Type: application/octet-stream',
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => $fileContent,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
return $httpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read platform from .mokogitea/manifest.xml ───────────────────────────────
|
||||||
|
|
||||||
|
$detectedPlatform = 'generic';
|
||||||
|
$detectedEntryPoint = '';
|
||||||
|
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||||
|
if (file_exists($mokoManifest)) {
|
||||||
|
$mokoXml = @simplexml_load_file($mokoManifest);
|
||||||
|
if ($mokoXml !== false) {
|
||||||
|
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
|
||||||
|
if ($rawPlatform !== '') {
|
||||||
|
$detectedPlatform = match ($rawPlatform) {
|
||||||
|
'waas-component' => 'joomla',
|
||||||
|
'crm-module' => 'dolibarr',
|
||||||
|
default => $rawPlatform,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
$detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detect element metadata from manifest XML ────────────────────────────────
|
||||||
|
|
||||||
|
$extElement = '';
|
||||||
|
$extType = '';
|
||||||
|
$extFolder = '';
|
||||||
|
$typePrefix = '';
|
||||||
|
|
||||||
|
$manifestFiles = array_merge(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
$extManifest = null;
|
||||||
|
foreach ($manifestFiles as $file) {
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
if ($content !== false && strpos($content, '<extension') !== false) {
|
||||||
|
$extManifest = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($extManifest !== null) {
|
||||||
|
$xml = file_get_contents($extManifest);
|
||||||
|
if ($xml === false) {
|
||||||
|
$xml = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension type and folder
|
||||||
|
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||||
|
$extType = $tm[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||||
|
$extFolder = $gm[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element name: <element>, plugin= attribute, <packagename>, or filename
|
||||||
|
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||||
|
$extElement = $em[1];
|
||||||
|
}
|
||||||
|
if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
|
||||||
|
$extElement = $pm[1];
|
||||||
|
}
|
||||||
|
// For packages: prefer <packagename> over filename
|
||||||
|
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||||
|
$extElement = $pn[1];
|
||||||
|
}
|
||||||
|
if ($extElement === '') {
|
||||||
|
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||||
|
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to repo name
|
||||||
|
if ($extElement === '') {
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip existing type prefix to prevent duplication
|
||||||
|
$extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
|
||||||
|
|
||||||
|
// Compute type prefix
|
||||||
|
switch ($extType) {
|
||||||
|
case 'plugin':
|
||||||
|
$typePrefix = "plg_{$extFolder}_";
|
||||||
|
break;
|
||||||
|
case 'module':
|
||||||
|
$typePrefix = 'mod_';
|
||||||
|
break;
|
||||||
|
case 'component':
|
||||||
|
$typePrefix = 'com_';
|
||||||
|
break;
|
||||||
|
case 'template':
|
||||||
|
$typePrefix = 'tpl_';
|
||||||
|
break;
|
||||||
|
case 'library':
|
||||||
|
$typePrefix = 'lib_';
|
||||||
|
break;
|
||||||
|
case 'package':
|
||||||
|
$typePrefix = 'pkg_';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Element: {$typePrefix}{$extElement}\n";
|
||||||
|
echo "Type: {$extType}\n";
|
||||||
|
|
||||||
|
// ── Compute filenames ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$baseName = "{$typePrefix}{$extElement}-{$version}";
|
||||||
|
$zipFile = "{$outputDir}/{$baseName}.zip";
|
||||||
|
$tarFile = "{$outputDir}/{$baseName}.tar.gz";
|
||||||
|
|
||||||
|
echo "ZIP: {$baseName}.zip\n";
|
||||||
|
echo "TAR: {$baseName}.tar.gz\n";
|
||||||
|
|
||||||
|
// ── Find source directory ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$sourceDir = null;
|
||||||
|
|
||||||
|
// Use entry-point from manifest.xml if available
|
||||||
|
if ($detectedEntryPoint !== '') {
|
||||||
|
$entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/');
|
||||||
|
if (is_dir("{$root}/{$entryDir}")) {
|
||||||
|
$sourceDir = "{$root}/{$entryDir}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to common directories
|
||||||
|
if ($sourceDir === null && is_dir("{$root}/src")) {
|
||||||
|
$sourceDir = "{$root}/src";
|
||||||
|
} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) {
|
||||||
|
$sourceDir = "{$root}/htdocs";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceDir === null) {
|
||||||
|
echo "No src/ or htdocs/ directory found — skipping package build\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Source: {$sourceDir}\n";
|
||||||
|
|
||||||
|
// ── File exclusion patterns ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
|
$excludePatterns = [
|
||||||
|
'sftp-config*',
|
||||||
|
'.ftpignore',
|
||||||
|
'*.ppk',
|
||||||
|
'*.pem',
|
||||||
|
'*.key',
|
||||||
|
'.env*',
|
||||||
|
'*.local',
|
||||||
|
'.build-trigger',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a filename matches any exclusion pattern.
|
||||||
|
*
|
||||||
|
* @param string $filename Filename to check
|
||||||
|
* @param array<int,string> $patterns Glob patterns to exclude
|
||||||
|
*
|
||||||
|
* @return bool True if the file should be excluded
|
||||||
|
*/
|
||||||
|
function isExcluded(string $filename, array $patterns): bool
|
||||||
|
{
|
||||||
|
$basename = basename($filename);
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
if (fnmatch($pattern, $basename)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively add files from a directory to a ZipArchive.
|
||||||
|
*
|
||||||
|
* @param ZipArchive $zip ZipArchive instance
|
||||||
|
* @param string $sourceDir Source directory path
|
||||||
|
* @param string $prefix Path prefix inside the archive
|
||||||
|
* @param array<int,string> $excludes Exclusion patterns
|
||||||
|
*/
|
||||||
|
function addDirToZip(ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void
|
||||||
|
{
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::LEAVES_ONLY
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (!$file instanceof SplFileInfo || !$file->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$realPath = $file->getRealPath();
|
||||||
|
if ($realPath === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExcluded($file->getFilename(), $excludes)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = substr($realPath, strlen($sourceDir) + 1);
|
||||||
|
// Normalise to forward slashes for ZIP compatibility
|
||||||
|
$relativePath = str_replace('\\', '/', $relativePath);
|
||||||
|
$archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath;
|
||||||
|
$zip->addFile($realPath, $archivePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build packages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
|
||||||
|
|
||||||
|
if ($isJoomlaPackage) {
|
||||||
|
// ── Joomla package: ZIP each sub-extension, then combine ─────────────────
|
||||||
|
echo "Building Joomla package (sub-extensions)...\n";
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
|
fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP each sub-extension directory
|
||||||
|
$packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: [];
|
||||||
|
foreach ($packageDirs as $pkgDir) {
|
||||||
|
$subName = basename($pkgDir);
|
||||||
|
$subZipPath = "{$outputDir}/{$subName}.zip";
|
||||||
|
|
||||||
|
$subZip = new ZipArchive();
|
||||||
|
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
|
fwrite(STDERR, "Failed to create sub-package ZIP: {$subZipPath}\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addDirToZip($subZip, $pkgDir, '', $excludePatterns);
|
||||||
|
$subZip->close();
|
||||||
|
|
||||||
|
$zip->addFile($subZipPath, "packages/{$subName}.zip");
|
||||||
|
echo " Sub-package: {$subName}.zip\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy top-level XML and PHP files into the package root
|
||||||
|
$topLevelFiles = array_merge(
|
||||||
|
glob("{$sourceDir}/*.xml") ?: [],
|
||||||
|
glob("{$sourceDir}/*.php") ?: []
|
||||||
|
);
|
||||||
|
foreach ($topLevelFiles as $tlFile) {
|
||||||
|
if (!isExcluded(basename($tlFile), $excludePatterns)) {
|
||||||
|
$zip->addFile($tlFile, basename($tlFile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include top-level directories (e.g. language/) that aren't packages/
|
||||||
|
$topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: [];
|
||||||
|
foreach ($topLevelDirs as $tlDir) {
|
||||||
|
$dirName = basename($tlDir);
|
||||||
|
if ($dirName === 'packages') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addDirToZip($zip, $tlDir, $dirName, $excludePatterns);
|
||||||
|
echo " Included dir: {$dirName}/\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
echo "ZIP created: {$zipFile}\n";
|
||||||
|
} else {
|
||||||
|
// ── Standard extension: ZIP from source dir ──────────────────────────────
|
||||||
|
echo "Building standard extension ZIP...\n";
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
|
fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
addDirToZip($zip, $sourceDir, '', $excludePatterns);
|
||||||
|
$zip->close();
|
||||||
|
echo "ZIP created: {$zipFile}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build tar.gz ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$tarExcludeArgs = [];
|
||||||
|
foreach ($excludePatterns as $pattern) {
|
||||||
|
$tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tarCommand = sprintf(
|
||||||
|
'tar -czf %s -C %s %s .',
|
||||||
|
escapeshellarg($tarFile),
|
||||||
|
escapeshellarg($sourceDir),
|
||||||
|
implode(' ', $tarExcludeArgs)
|
||||||
|
);
|
||||||
|
|
||||||
|
$tarReturnCode = 0;
|
||||||
|
$tarOutputLines = [];
|
||||||
|
exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode);
|
||||||
|
|
||||||
|
if (!file_exists($tarFile)) {
|
||||||
|
fwrite(STDERR, "Failed to create tar.gz: {$tarFile}\n");
|
||||||
|
if ($tarOutputLines !== []) {
|
||||||
|
fwrite(STDERR, implode("\n", $tarOutputLines) . "\n");
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo "TAR created: {$tarFile}\n";
|
||||||
|
|
||||||
|
// ── Compute SHA-256 checksums ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$zipHash = hash_file('sha256', $zipFile);
|
||||||
|
$tarHash = hash_file('sha256', $tarFile);
|
||||||
|
|
||||||
|
if ($zipHash === false || $tarHash === false) {
|
||||||
|
fwrite(STDERR, "Failed to compute SHA-256 checksums\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zipSha = "{$zipFile}.sha256";
|
||||||
|
$tarSha = "{$tarFile}.sha256";
|
||||||
|
|
||||||
|
file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n");
|
||||||
|
file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n");
|
||||||
|
|
||||||
|
echo "SHA-256 (ZIP): {$zipHash}\n";
|
||||||
|
echo "SHA-256 (TAR): {$tarHash}\n";
|
||||||
|
echo "sha256_zip={$zipHash}\n";
|
||||||
|
echo "zip_name={$baseName}.zip\n";
|
||||||
|
|
||||||
|
// Write to GITHUB_OUTPUT if available
|
||||||
|
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||||
|
if ($ghOutput) {
|
||||||
|
file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get release ID from tag ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$result = giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token);
|
||||||
|
if ($result['data'] === null || !isset($result['data']['id'])) {
|
||||||
|
fwrite(STDERR, "No release found for tag: {$tag} (HTTP {$result['code']})\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseId = (int) $result['data']['id'];
|
||||||
|
echo "Release ID: {$releaseId} (tag: {$tag})\n";
|
||||||
|
|
||||||
|
// ── Delete existing assets with same names ───────────────────────────────────
|
||||||
|
|
||||||
|
$assetsResult = giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token);
|
||||||
|
$existingAssets = $assetsResult['data'] ?? [];
|
||||||
|
|
||||||
|
$uploadNames = [
|
||||||
|
"{$baseName}.zip",
|
||||||
|
"{$baseName}.tar.gz",
|
||||||
|
"{$baseName}.zip.sha256",
|
||||||
|
"{$baseName}.tar.gz.sha256",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($existingAssets as $asset) {
|
||||||
|
if (!is_array($asset)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$assetName = $asset['name'] ?? '';
|
||||||
|
$assetId = $asset['id'] ?? 0;
|
||||||
|
if (in_array($assetName, $uploadNames, true) && $assetId > 0) {
|
||||||
|
giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE');
|
||||||
|
echo "Deleted existing asset: {$assetName}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upload assets ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$filesToUpload = [
|
||||||
|
"{$baseName}.zip" => $zipFile,
|
||||||
|
"{$baseName}.tar.gz" => $tarFile,
|
||||||
|
"{$baseName}.zip.sha256" => $zipSha,
|
||||||
|
"{$baseName}.tar.gz.sha256" => $tarSha,
|
||||||
|
];
|
||||||
|
|
||||||
|
$uploaded = 0;
|
||||||
|
foreach ($filesToUpload as $name => $localPath) {
|
||||||
|
if (!file_exists($localPath)) {
|
||||||
|
fwrite(STDERR, "File not found, skipping: {$localPath}\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name);
|
||||||
|
$httpCode = giteaUploadAsset($uploadUrl, $token, $localPath);
|
||||||
|
$status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})";
|
||||||
|
echo "Upload: {$name} — {$status}\n";
|
||||||
|
|
||||||
|
if ($httpCode >= 200 && $httpCode < 300) {
|
||||||
|
$uploaded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
echo "Package build complete\n";
|
||||||
|
echo " Element: {$typePrefix}{$extElement}\n";
|
||||||
|
echo " Version: {$version}\n";
|
||||||
|
echo " Tag: {$tag}\n";
|
||||||
|
echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n";
|
||||||
|
|
||||||
|
exit($uploaded === count($filesToUpload) ? 0 : 1);
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* FILE INFORMATION
|
||||||
|
* DEFGROUP: moko-platform.CLI
|
||||||
|
* INGROUP: moko-platform
|
||||||
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
* PATH: /cli/release_promote.php
|
||||||
|
* BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php release_promote.php --from development --to release-candidate --token TOKEN --api-base URL
|
||||||
|
* php release_promote.php --from release-candidate --to stable --token TOKEN --api-base URL --path .
|
||||||
|
*
|
||||||
|
* When promoting to stable, --path detects extension type prefix for asset renaming.
|
||||||
|
* When --from is "auto", checks beta > alpha > development and uses the first found.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$from = null;
|
||||||
|
$to = null;
|
||||||
|
$token = null;
|
||||||
|
$apiBase = null;
|
||||||
|
$path = '.';
|
||||||
|
$branch = 'main';
|
||||||
|
|
||||||
|
foreach ($argv as $i => $arg) {
|
||||||
|
if ($arg === '--from' && isset($argv[$i + 1])) {
|
||||||
|
$from = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--to' && isset($argv[$i + 1])) {
|
||||||
|
$to = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||||
|
$token = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||||
|
$apiBase = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||||
|
$path = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||||
|
$branch = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $token ?: (getenv('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
|
||||||
|
|
||||||
|
if ($to === null || $token === null || $apiBase === null) {
|
||||||
|
fwrite(STDERR, "Usage: release_promote.php --from <channel|auto> --to <channel> --token TOKEN --api-base URL [--path .]\n");
|
||||||
|
fwrite(STDERR, " --from auto: checks beta > alpha > development\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Suffix maps ──────────────────────────────────────────────────────────────
|
||||||
|
$suffixMap = [
|
||||||
|
'development' => '-dev',
|
||||||
|
'alpha' => '-alpha',
|
||||||
|
'beta' => '-beta',
|
||||||
|
'release-candidate' => '-rc',
|
||||||
|
'stable' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Channel hierarchy (highest first) ────────────────────────────────────────
|
||||||
|
$channelOrder = ['beta', 'alpha', 'development'];
|
||||||
|
|
||||||
|
// ── Helper: Gitea API request ────────────────────────────────────────────────
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: token {$token}",
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
]);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return json_decode($response, true) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaDownload(string $url, string $token, string $dest): bool
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$fp = fopen($dest, 'wb');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||||
|
CURLOPT_FILE => $fp,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
fclose($fp);
|
||||||
|
return $httpCode >= 200 && $httpCode < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve --from auto ──────────────────────────────────────────────────────
|
||||||
|
if ($from === 'auto') {
|
||||||
|
foreach ($channelOrder as $candidate) {
|
||||||
|
$data = giteaApi("{$apiBase}/releases/tags/{$candidate}", $token);
|
||||||
|
if ($data && !empty($data['id'])) {
|
||||||
|
$from = $candidate;
|
||||||
|
echo "Auto-detected source channel: {$from}\n";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($from === 'auto') {
|
||||||
|
echo "No pre-release found to promote\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find source release ──────────────────────────────────────────────────────
|
||||||
|
$sourceRelease = giteaApi("{$apiBase}/releases/tags/{$from}", $token);
|
||||||
|
if (!$sourceRelease || empty($sourceRelease['id'])) {
|
||||||
|
fwrite(STDERR, "No release found with tag: {$from}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceId = $sourceRelease['id'];
|
||||||
|
$sourceName = $sourceRelease['name'] ?? '';
|
||||||
|
$sourceBody = $sourceRelease['body'] ?? '';
|
||||||
|
echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n";
|
||||||
|
|
||||||
|
// ── Get source assets ────────────────────────────────────────────────────────
|
||||||
|
$assets = giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: [];
|
||||||
|
echo "Assets: " . count($assets) . " file(s)\n";
|
||||||
|
|
||||||
|
// ── Download assets to temp ──────────────────────────────────────────────────
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid();
|
||||||
|
@mkdir($tmpDir, 0755, true);
|
||||||
|
|
||||||
|
foreach ($assets as $asset) {
|
||||||
|
$name = $asset['name'];
|
||||||
|
$downloadUrl = $asset['browser_download_url'];
|
||||||
|
echo " Downloading: {$name}\n";
|
||||||
|
giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detect type prefix for stable promotion ──────────────────────────────────
|
||||||
|
$typePrefix = '';
|
||||||
|
if ($to === 'stable') {
|
||||||
|
$root = realpath($path) ?: $path;
|
||||||
|
$manifestFiles = array_merge(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
foreach ($manifestFiles as $xmlFile) {
|
||||||
|
$xmlContent = file_get_contents($xmlFile);
|
||||||
|
if (strpos($xmlContent, '<extension') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extType = '';
|
||||||
|
$extFolder = '';
|
||||||
|
if (preg_match('/type="([^"]*)"/', $xmlContent, $tm)) {
|
||||||
|
$extType = $tm[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/group="([^"]*)"/', $xmlContent, $gm)) {
|
||||||
|
$extFolder = $gm[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($extType) {
|
||||||
|
case 'plugin':
|
||||||
|
$typePrefix = "plg_{$extFolder}_";
|
||||||
|
break;
|
||||||
|
case 'module':
|
||||||
|
$typePrefix = 'mod_';
|
||||||
|
break;
|
||||||
|
case 'component':
|
||||||
|
$typePrefix = 'com_';
|
||||||
|
break;
|
||||||
|
case 'template':
|
||||||
|
$typePrefix = 'tpl_';
|
||||||
|
break;
|
||||||
|
case 'library':
|
||||||
|
$typePrefix = 'lib_';
|
||||||
|
break;
|
||||||
|
case 'package':
|
||||||
|
$typePrefix = 'pkg_';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ($typePrefix !== '') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rename assets ────────────────────────────────────────────────────────────
|
||||||
|
$oldSuffix = $suffixMap[$from] ?? '';
|
||||||
|
$newSuffix = $suffixMap[$to] ?? '';
|
||||||
|
|
||||||
|
$renamedAssets = [];
|
||||||
|
foreach ($assets as $asset) {
|
||||||
|
$oldName = $asset['name'];
|
||||||
|
$newName = $oldName;
|
||||||
|
|
||||||
|
// Strip old suffix
|
||||||
|
if ($oldSuffix !== '') {
|
||||||
|
$newName = str_replace($oldSuffix, '', $newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add type prefix for stable (if not already prefixed)
|
||||||
|
if ($to === 'stable' && $typePrefix !== '' && strpos($newName, $typePrefix) !== 0) {
|
||||||
|
// Strip any existing type prefix to prevent duplication
|
||||||
|
$newName = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $newName);
|
||||||
|
$newName = $typePrefix . $newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new suffix (for non-stable targets)
|
||||||
|
if ($newSuffix !== '' && strpos($newName, $newSuffix) === false) {
|
||||||
|
// Insert before extension
|
||||||
|
$newName = preg_replace('/(\.(zip|tar\.gz|sha256))$/', $newSuffix . '$1', $newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$renamedAssets[] = ['old' => $oldName, 'new' => $newName];
|
||||||
|
if ($oldName !== $newName) {
|
||||||
|
echo " Rename: {$oldName} → {$newName}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete source release + tag ──────────────────────────────────────────────
|
||||||
|
giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE');
|
||||||
|
giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE');
|
||||||
|
echo "Deleted source: {$from} release + tag\n";
|
||||||
|
|
||||||
|
// ── Delete existing target release + tag (if any) ────────────────────────────
|
||||||
|
$existingTarget = giteaApi("{$apiBase}/releases/tags/{$to}", $token);
|
||||||
|
if ($existingTarget && !empty($existingTarget['id'])) {
|
||||||
|
giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE');
|
||||||
|
giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE');
|
||||||
|
echo "Deleted existing target: {$to} release + tag\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create target release ────────────────────────────────────────────────────
|
||||||
|
$isPrerelease = ($to !== 'stable');
|
||||||
|
$newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName);
|
||||||
|
if ($newName === $sourceName) {
|
||||||
|
$newName = str_ireplace($from, $to, $sourceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newBody = str_ireplace($from, $to, $sourceBody);
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'tag_name' => $to,
|
||||||
|
'target_commitish' => $branch,
|
||||||
|
'name' => $newName,
|
||||||
|
'body' => $newBody,
|
||||||
|
'prerelease' => $isPrerelease,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload);
|
||||||
|
if (!$newRelease || empty($newRelease['id'])) {
|
||||||
|
fwrite(STDERR, "Failed to create {$to} release\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newId = $newRelease['id'];
|
||||||
|
echo "Created: {$to} release (id: {$newId})\n";
|
||||||
|
|
||||||
|
// ── Upload renamed assets ────────────────────────────────────────────────────
|
||||||
|
foreach ($renamedAssets as $entry) {
|
||||||
|
$localFile = "{$tmpDir}/{$entry['old']}";
|
||||||
|
if (!file_exists($localFile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadName = urlencode($entry['new']);
|
||||||
|
$url = "{$apiBase}/releases/{$newId}/assets?name={$uploadName}";
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: token {$token}",
|
||||||
|
'Content-Type: application/octet-stream',
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => file_get_contents($localFile),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
|
||||||
|
echo " Upload: {$entry['new']} — {$status}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup temp ─────────────────────────────────────────────────────────────
|
||||||
|
array_map('unlink', glob("{$tmpDir}/*") ?: []);
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
|
||||||
|
echo "Promoted: {$from} → {$to}\n";
|
||||||
|
exit(0);
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
#!/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 = null;
|
||||||
|
$outputSummary = false;
|
||||||
|
$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 === '--platform' && isset($argv[$i + 1])) {
|
||||||
|
$platform = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--output-summary') {
|
||||||
|
$outputSummary = true;
|
||||||
|
}
|
||||||
|
if ($arg === '--github-output') {
|
||||||
|
$githubOutput = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($version === null) {
|
||||||
|
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = realpath($path) ?: $path;
|
||||||
|
|
||||||
|
// Auto-detect platform from manifest.xml if not specified
|
||||||
|
if ($platform === null) {
|
||||||
|
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||||
|
if (file_exists($manifestXml)) {
|
||||||
|
$mContent = file_get_contents($manifestXml);
|
||||||
|
if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) {
|
||||||
|
$platform = trim($pm[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normalize platform aliases
|
||||||
|
if (in_array($platform, ['waas-component'], true)) {
|
||||||
|
$platform = 'joomla';
|
||||||
|
}
|
||||||
|
if (in_array($platform, ['crm-module'], true)) {
|
||||||
|
$platform = 'dolibarr';
|
||||||
|
}
|
||||||
|
if ($platform === null) {
|
||||||
|
$platform = 'generic';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pass = 0;
|
||||||
|
$fail = 0;
|
||||||
|
$warn = 0;
|
||||||
|
/** @var array<int, array{check: string, status: string, details: string}> */
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a validation result.
|
||||||
|
*
|
||||||
|
* @param string $check Check name
|
||||||
|
* @param string $status PASS, FAIL, or WARN
|
||||||
|
* @param string $details Human-readable details
|
||||||
|
*/
|
||||||
|
function addResult(string $check, string $status, string $details): void
|
||||||
|
{
|
||||||
|
global $pass, $fail, $warn, $results;
|
||||||
|
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||||
|
if ($status === 'PASS') {
|
||||||
|
$pass++;
|
||||||
|
} elseif ($status === 'FAIL') {
|
||||||
|
$fail++;
|
||||||
|
} elseif ($status === 'WARN') {
|
||||||
|
$warn++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. Source directory check
|
||||||
|
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
|
||||||
|
if ($hasSource) {
|
||||||
|
addResult('Source directory', 'PASS', 'src/ or htdocs/ found');
|
||||||
|
} else {
|
||||||
|
addResult('Source directory', 'WARN', 'No src/ or htdocs/ directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. README.md exists and contains VERSION
|
||||||
|
if (!file_exists("{$root}/README.md")) {
|
||||||
|
addResult('README.md', 'FAIL', 'Not found');
|
||||||
|
} else {
|
||||||
|
$readme = file_get_contents("{$root}/README.md");
|
||||||
|
if (
|
||||||
|
preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) ||
|
||||||
|
strpos($readme, $version) !== false
|
||||||
|
) {
|
||||||
|
addResult('README.md version', 'PASS', "`{$version}` found");
|
||||||
|
} else {
|
||||||
|
addResult('README.md version', 'FAIL', "`{$version}` not found in README.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CHANGELOG.md exists with matching section
|
||||||
|
if (!file_exists("{$root}/CHANGELOG.md")) {
|
||||||
|
addResult('CHANGELOG.md', 'WARN', 'Not found');
|
||||||
|
} else {
|
||||||
|
$cl = file_get_contents("{$root}/CHANGELOG.md");
|
||||||
|
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl)) {
|
||||||
|
addResult('CHANGELOG.md version', 'PASS', "Section for `{$version}` found");
|
||||||
|
} else {
|
||||||
|
addResult('CHANGELOG.md version', 'WARN', "No section header for `{$version}`");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. LICENSE file exists
|
||||||
|
$licenseFound = false;
|
||||||
|
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
|
||||||
|
if (file_exists("{$root}/{$lf}")) {
|
||||||
|
$licenseFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
|
||||||
|
|
||||||
|
// 4. Platform-specific checks
|
||||||
|
if ($platform === 'joomla') {
|
||||||
|
// Find XML manifest
|
||||||
|
$manifest = null;
|
||||||
|
$searchDirs = ["{$root}/src", $root];
|
||||||
|
foreach ($searchDirs as $dir) {
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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 Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($githubOutput) {
|
||||||
|
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||||
|
$lines = [
|
||||||
|
"validation_pass={$pass}",
|
||||||
|
"validation_fail={$fail}",
|
||||||
|
"validation_warn={$warn}",
|
||||||
|
"validation_platform={$platform}",
|
||||||
|
];
|
||||||
|
if ($ghOutput) {
|
||||||
|
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exit($fail > 0 ? 1 : 0);
|
||||||
@@ -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
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/sync_rulesets.php
|
* PATH: /cli/sync_rulesets.php
|
||||||
* VERSION: 04.06.10
|
|
||||||
* BRIEF: Apply branch protection rules to all repos via platform adapter
|
* BRIEF: Apply branch protection rules to all repos via platform adapter
|
||||||
*
|
*
|
||||||
* USAGE
|
* USAGE
|
||||||
* php api/cli/sync_rulesets.php # Apply to all repos
|
* php cli/sync_rulesets.php # Apply to all repos
|
||||||
* php api/cli/sync_rulesets.php --repo MokoCRM # Single repo
|
* php cli/sync_rulesets.php --repo MokoCRM # Single repo
|
||||||
* php api/cli/sync_rulesets.php --dry-run # Preview only
|
* php cli/sync_rulesets.php --dry-run # Preview only
|
||||||
* php api/cli/sync_rulesets.php --delete # Remove then re-apply
|
* php cli/sync_rulesets.php --delete # Remove then re-apply
|
||||||
*
|
*
|
||||||
* NOTE: On GitHub, this creates rulesets via the rulesets API.
|
* NOTE: On GitHub, this creates rulesets via the rulesets API.
|
||||||
* On Gitea, this creates branch_protections via the branch protection 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,470 @@
|
|||||||
|
#!/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;
|
||||||
|
|
||||||
|
// -- Read platform from .mokogitea/manifest.xml --------------------------------
|
||||||
|
$detectedPlatform = 'joomla'; // default for backward compat
|
||||||
|
$detectedName = $repo;
|
||||||
|
$detectedPackageType = '';
|
||||||
|
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||||
|
if (file_exists($mokoManifest)) {
|
||||||
|
$mokoXml = @simplexml_load_file($mokoManifest);
|
||||||
|
if ($mokoXml !== false) {
|
||||||
|
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
|
||||||
|
if ($rawPlatform !== '') {
|
||||||
|
$detectedPlatform = match ($rawPlatform) {
|
||||||
|
'waas-component' => 'joomla',
|
||||||
|
'crm-module' => 'dolibarr',
|
||||||
|
default => $rawPlatform,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
$detectedName = (string)($mokoXml->identity->name ?? $repo);
|
||||||
|
$detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 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 && $detectedPlatform === 'joomla') {
|
||||||
|
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Parse extension metadata -------------------------------------------------
|
||||||
|
$extName = '';
|
||||||
|
$extType = '';
|
||||||
|
$extElement = '';
|
||||||
|
$extClient = '';
|
||||||
|
$extFolder = '';
|
||||||
|
$targetPlatform = '';
|
||||||
|
$phpMinimum = '';
|
||||||
|
|
||||||
|
if ($manifest !== null) {
|
||||||
|
// Joomla manifest found — parse extension metadata from it
|
||||||
|
$xml = file_get_contents($manifest);
|
||||||
|
|
||||||
|
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
|
||||||
|
$extName = $m[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
|
||||||
|
$extType = $m[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
|
||||||
|
$extElement = $m[1];
|
||||||
|
}
|
||||||
|
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) {
|
||||||
|
$extElement = $m[1];
|
||||||
|
}
|
||||||
|
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) {
|
||||||
|
$extElement = $m[1];
|
||||||
|
}
|
||||||
|
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) {
|
||||||
|
$extElement = $m[1];
|
||||||
|
}
|
||||||
|
if (empty($extElement)) {
|
||||||
|
$fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||||
|
if (in_array($fname, ['templatedetails', 'manifest'])) {
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root)));
|
||||||
|
} else {
|
||||||
|
$extElement = $fname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
|
||||||
|
|
||||||
|
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) {
|
||||||
|
$extClient = $m[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
|
||||||
|
$extFolder = $m[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) {
|
||||||
|
$targetPlatform = $m[1];
|
||||||
|
}
|
||||||
|
if (empty($targetPlatform)) {
|
||||||
|
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
|
||||||
|
}
|
||||||
|
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) {
|
||||||
|
$phpMinimum = $m[1];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-Joomla platform — derive metadata from .mokogitea/manifest.xml
|
||||||
|
$extName = $detectedName ?: ($repo ?: basename($root));
|
||||||
|
$extElement = strtolower(str_replace([' ', '-'], '', $extName));
|
||||||
|
$extType = $detectedPackageType ?: 'generic';
|
||||||
|
$targetPlatform = "<targetplatform name=\"{$detectedPlatform}\" version=\".*\" />";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 — Joomla requires <client>site</client> to match updates
|
||||||
|
// to installed extensions. Without it, extension_id=0 in #__updates.
|
||||||
|
$clientTag = '';
|
||||||
|
if (!empty($extClient)) {
|
||||||
|
$clientTag = " <client>{$extClient}</client>";
|
||||||
|
} else {
|
||||||
|
$clientTag = ' <client>site</client>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build folder tag
|
||||||
|
$folderTag = '';
|
||||||
|
if (!empty($extFolder) && $extType === 'plugin') {
|
||||||
|
$folderTag = " <folder>{$extFolder}</folder>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHP minimum tag
|
||||||
|
$phpTag = '';
|
||||||
|
if (!empty($phpMinimum)) {
|
||||||
|
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA tag
|
||||||
|
$shaTag = '';
|
||||||
|
if (!empty($sha)) {
|
||||||
|
$shaTag = " <sha256>{$sha}</sha256>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a single <update> entry for a given stability tag
|
||||||
|
*/
|
||||||
|
function buildEntry(
|
||||||
|
string $tagName,
|
||||||
|
string $entryVersion,
|
||||||
|
string $entryDownloadUrl,
|
||||||
|
string $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];
|
||||||
|
}
|
||||||
|
// Also match legacy/alternate tag names (e.g. 'development' = 'dev')
|
||||||
|
$writtenChannels[] = 'development'; // alias for 'dev'
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
+163
-22
@@ -5,12 +5,11 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/version_bump.php
|
* PATH: /cli/version_bump.php
|
||||||
* VERSION: 04.06.00
|
* BRIEF: Auto-increment version — manifest.xml is canonical, cascades to all XML and MD files
|
||||||
* BRIEF: Auto-increment patch version in README.md — outputs old → new
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -23,21 +22,81 @@ foreach ($argv as $i => $arg) {
|
|||||||
if ($arg === '--major') $type = 'major';
|
if ($arg === '--major') $type = 'major';
|
||||||
}
|
}
|
||||||
|
|
||||||
$readme = realpath($path) . '/README.md';
|
$root = realpath($path) ?: $path;
|
||||||
if (!file_exists($readme)) {
|
|
||||||
fwrite(STDERR, "No README.md found at {$path}\n");
|
// -- 1. Read version from .mokogitea/manifest.xml (canonical) --
|
||||||
|
$mokoVersion = null;
|
||||||
|
$mokoSuffix = '';
|
||||||
|
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||||
|
$mokoContent = '';
|
||||||
|
if (file_exists($mokoManifest)) {
|
||||||
|
$mokoContent = file_get_contents($mokoManifest);
|
||||||
|
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-([a-z]+))?</version>|', $mokoContent, $m)) {
|
||||||
|
$mokoVersion = $m[1];
|
||||||
|
$mokoSuffix = isset($m[2]) ? $m[2] : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 2. Fallback: README.md --
|
||||||
|
$readmeVersion = null;
|
||||||
|
$readme = "{$root}/README.md";
|
||||||
|
$readmeContent = '';
|
||||||
|
if (file_exists($readme)) {
|
||||||
|
$readmeContent = file_get_contents($readme);
|
||||||
|
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
|
||||||
|
$readmeVersion = $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 3. Fallback: Joomla manifest XML --
|
||||||
|
$manifestVersion = null;
|
||||||
|
$manifestSuffix = '';
|
||||||
|
$manifestFiles = array_merge(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
|
||||||
|
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($manifestFiles as $xmlFile) {
|
||||||
|
$xmlContent = file_get_contents($xmlFile);
|
||||||
|
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||||
|
$candidate = $xm[1];
|
||||||
|
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||||
|
$manifestVersion = $candidate;
|
||||||
|
// Preserve the suffix from the manifest (e.g. dev, rc) — strip leading dash
|
||||||
|
$manifestSuffix = ltrim($xm[2] ?? '', '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Use the highest version as base --
|
||||||
|
$baseVersion = null;
|
||||||
|
$candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
|
||||||
|
foreach ($candidates as $v) {
|
||||||
|
if ($baseVersion === null || version_compare($v, $baseVersion, '>')) {
|
||||||
|
$baseVersion = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($baseVersion === null) {
|
||||||
|
fwrite(STDERR, "No version found in manifest.xml, README.md, or Joomla XML\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = file_get_contents($readme);
|
// -- Parse and bump --
|
||||||
if (!preg_match('/^(\s*VERSION:\s*)(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) {
|
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) {
|
||||||
fwrite(STDERR, "No VERSION field found in README.md\n");
|
fwrite(STDERR, "Invalid version format: {$baseVersion}\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$major = (int)$m[2];
|
$major = (int)$parts[1];
|
||||||
$minor = (int)$m[3];
|
$minor = (int)$parts[2];
|
||||||
$patch = (int)$m[4];
|
$patch = (int)$parts[3];
|
||||||
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||||
|
|
||||||
switch ($type) {
|
switch ($type) {
|
||||||
@@ -51,13 +110,95 @@ switch ($type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$new = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
$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);
|
// -- Determine suffix to preserve (moko manifest takes priority, then Joomla) --
|
||||||
echo "{$old} → {$new}\n";
|
$suffix = !empty($mokoSuffix) ? $mokoSuffix : (!empty($manifestSuffix) ? $manifestSuffix : '');
|
||||||
|
$newFull = $suffix !== '' ? "{$new}-{$suffix}" : $new;
|
||||||
|
|
||||||
|
// -- Update .mokogitea/manifest.xml (canonical — preserves suffix) --
|
||||||
|
if (file_exists($mokoManifest) && !empty($mokoContent)) {
|
||||||
|
$updated = preg_replace(
|
||||||
|
'|<version>\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?</version>|',
|
||||||
|
"<version>{$newFull}</version>",
|
||||||
|
$mokoContent,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
file_put_contents($mokoManifest, $updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Update README.md --
|
||||||
|
if (file_exists($readme) && !empty($readmeContent)) {
|
||||||
|
$updated = preg_replace(
|
||||||
|
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?/m',
|
||||||
|
'${1}' . $newFull,
|
||||||
|
$readmeContent,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
file_put_contents($readme, $updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Cascade to ALL Joomla extension XML manifests --
|
||||||
|
$xmlPatterns = [
|
||||||
|
"{$root}/src/pkg_*.xml",
|
||||||
|
"{$root}/src/*.xml",
|
||||||
|
"{$root}/src/packages/*/*.xml",
|
||||||
|
"{$root}/*.xml",
|
||||||
|
];
|
||||||
|
|
||||||
|
$updatedFiles = [];
|
||||||
|
foreach ($xmlPatterns as $pattern) {
|
||||||
|
foreach (glob($pattern) ?: [] as $xmlFile) {
|
||||||
|
$content = file_get_contents($xmlFile);
|
||||||
|
// Only update files that have an <extension> tag (Joomla manifests)
|
||||||
|
if (strpos($content, '<extension') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$newContent = preg_replace(
|
||||||
|
'|<version>\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?</version>|',
|
||||||
|
"<version>{$newFull}</version>",
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
if ($newContent !== $content) {
|
||||||
|
file_put_contents($xmlFile, $newContent);
|
||||||
|
$updatedFiles[] = substr($xmlFile, strlen($root) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($updatedFiles)) {
|
||||||
|
fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Update package.json (Node.js / MCP) --
|
||||||
|
$packageJsonFile = "{$root}/package.json";
|
||||||
|
if (file_exists($packageJsonFile)) {
|
||||||
|
$pkgContent = file_get_contents($packageJsonFile);
|
||||||
|
$updatedPkg = preg_replace(
|
||||||
|
'/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?(")/m',
|
||||||
|
'${1}' . $newFull . '${2}',
|
||||||
|
$pkgContent
|
||||||
|
);
|
||||||
|
if ($updatedPkg !== $pkgContent) {
|
||||||
|
file_put_contents($packageJsonFile, $updatedPkg);
|
||||||
|
fwrite(STDERR, "Updated package.json\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Update pyproject.toml (Python) --
|
||||||
|
$pyprojectFile = "{$root}/pyproject.toml";
|
||||||
|
if (file_exists($pyprojectFile)) {
|
||||||
|
$pyContent = file_get_contents($pyprojectFile);
|
||||||
|
$updatedPy = preg_replace(
|
||||||
|
'/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?(")/m',
|
||||||
|
'${1}' . $newFull . '${2}',
|
||||||
|
$pyContent
|
||||||
|
);
|
||||||
|
if ($updatedPy !== $pyContent) {
|
||||||
|
file_put_contents($pyprojectFile, $updatedPy);
|
||||||
|
fwrite(STDERR, "Updated pyproject.toml\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldFull = $suffix !== '' ? "{$old}-{$suffix}" : $old;
|
||||||
|
echo "{$oldFull} -> {$newFull}\n";
|
||||||
exit(0);
|
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);
|
||||||
+120
-14
@@ -5,12 +5,11 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/version_read.php
|
* PATH: /cli/version_read.php
|
||||||
* VERSION: 04.06.00
|
* BRIEF: Read version — manifest.xml is canonical, falls back to README.md and Joomla XML
|
||||||
* BRIEF: Read VERSION from README.md — outputs just the version string
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -22,17 +21,124 @@ foreach ($argv as $i => $arg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$readme = realpath($path) . '/README.md';
|
$root = realpath($path) ?: $path;
|
||||||
if (!file_exists($readme)) {
|
|
||||||
fwrite(STDERR, "No README.md found at {$path}\n");
|
// -- 1. Read from .mokogitea/manifest.xml (canonical source) --
|
||||||
exit(1);
|
$mokoVersion = null;
|
||||||
|
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||||
|
if (file_exists($mokoManifest)) {
|
||||||
|
$xml = @simplexml_load_file($mokoManifest);
|
||||||
|
if ($xml !== false) {
|
||||||
|
$v = (string)($xml->identity->version ?? '');
|
||||||
|
if (preg_match('/^\d{2}\.\d{2}\.\d{2}(-[a-z]+)?$/', $v)) {
|
||||||
|
$mokoVersion = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = file_get_contents($readme);
|
// If manifest.xml has a version, that is authoritative
|
||||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
if ($mokoVersion !== null) {
|
||||||
echo $m[1] . "\n";
|
echo $mokoVersion . "\n";
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
fwrite(STDERR, "No VERSION field found in README.md\n");
|
// -- 2. Fallback: README.md --
|
||||||
exit(1);
|
$readmeVersion = null;
|
||||||
|
$readme = "{$root}/README.md";
|
||||||
|
if (file_exists($readme)) {
|
||||||
|
$content = file_get_contents($readme);
|
||||||
|
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||||
|
$readmeVersion = $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 3. Fallback: Joomla manifest XML --
|
||||||
|
$manifestVersion = null;
|
||||||
|
$manifestFiles = array_merge(
|
||||||
|
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||||
|
glob("{$root}/src/*.xml") ?: [],
|
||||||
|
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||||
|
glob("{$root}/*.xml") ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($manifestFiles as $xmlFile) {
|
||||||
|
$xmlContent = file_get_contents($xmlFile);
|
||||||
|
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?)</version>|', $xmlContent, $xm)) {
|
||||||
|
$candidate = $xm[1];
|
||||||
|
$candidateBase = preg_replace('/-[a-z]+$/', '', $candidate);
|
||||||
|
$currentBase = $manifestVersion ? preg_replace('/-[a-z]+$/', '', $manifestVersion) : null;
|
||||||
|
if ($currentBase === null || version_compare($candidateBase, $currentBase, '>')) {
|
||||||
|
$manifestVersion = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 4. Fallback: package.json (Node.js / MCP) --
|
||||||
|
$packageJsonVersion = null;
|
||||||
|
$packageJsonFile = "{$root}/package.json";
|
||||||
|
if (file_exists($packageJsonFile)) {
|
||||||
|
$pkgData = json_decode(file_get_contents($packageJsonFile), true);
|
||||||
|
if (isset($pkgData['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkgData['version'])) {
|
||||||
|
$packageJsonVersion = $pkgData['version'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 5. Fallback: pyproject.toml (Python) --
|
||||||
|
$pyprojectVersion = null;
|
||||||
|
$pyprojectFile = "{$root}/pyproject.toml";
|
||||||
|
if (file_exists($pyprojectFile)) {
|
||||||
|
$pyContent = file_get_contents($pyprojectFile);
|
||||||
|
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $pyContent, $pm)) {
|
||||||
|
$pyprojectVersion = $pm[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Output the higher version --
|
||||||
|
$candidates = array_filter([
|
||||||
|
$readmeVersion,
|
||||||
|
$manifestVersion,
|
||||||
|
$packageJsonVersion,
|
||||||
|
$pyprojectVersion,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = null;
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if ($version === null || version_compare($candidate, $version, '>')) {
|
||||||
|
$version = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($version === null) {
|
||||||
|
fwrite(STDERR, "No version found in manifest.xml, README.md, Joomla XML, package.json, or pyproject.toml\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Backfill: if manifest.xml exists but lacks <version>, insert it --
|
||||||
|
if (file_exists($mokoManifest)) {
|
||||||
|
$content = file_get_contents($mokoManifest);
|
||||||
|
if (!preg_match('|<version>\d{2}\.\d{2}\.\d{2}(-[a-z]+)?</version>|', $content)) {
|
||||||
|
if (strpos($content, '<license') !== false) {
|
||||||
|
$content = preg_replace(
|
||||||
|
'|(\s*<license)|',
|
||||||
|
"\n <version>{$version}</version>\$1",
|
||||||
|
$content,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} elseif (strpos($content, '</identity>') !== false) {
|
||||||
|
$content = preg_replace(
|
||||||
|
'|(</identity>)|',
|
||||||
|
" <version>{$version}</version>\n \$1",
|
||||||
|
$content,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
file_put_contents($mokoManifest, $content);
|
||||||
|
fwrite(STDERR, "Backfilled manifest.xml with version {$version}\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $version . "\n";
|
||||||
|
exit(0);
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* FILE INFORMATION
|
||||||
|
* DEFGROUP: moko-platform.CLI
|
||||||
|
* INGROUP: moko-platform
|
||||||
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
* PATH: /cli/version_reset_dev.php
|
||||||
|
* BRIEF: Reset platform version to 'development' on a branch via Gitea API
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php version_reset_dev.php --token TOKEN --api-base URL
|
||||||
|
* php version_reset_dev.php --token TOKEN --api-base URL --branch dev
|
||||||
|
* php version_reset_dev.php --token TOKEN --api-base URL --platform dolibarr
|
||||||
|
* php version_reset_dev.php --token TOKEN --api-base URL --path /repo/root
|
||||||
|
*
|
||||||
|
* This replaces the inline curl+python3+sed block previously used in
|
||||||
|
* auto-release.yml to reset Dolibarr's $this->version on the dev branch
|
||||||
|
* after a stable release.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// ── Argument parsing ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$token = null;
|
||||||
|
$apiBase = null;
|
||||||
|
$branch = 'dev';
|
||||||
|
$platform = null;
|
||||||
|
$path = null;
|
||||||
|
|
||||||
|
foreach ($argv as $i => $arg) {
|
||||||
|
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||||
|
$token = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||||
|
$apiBase = rtrim($argv[$i + 1], '/');
|
||||||
|
}
|
||||||
|
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||||
|
$branch = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--platform' && isset($argv[$i + 1])) {
|
||||||
|
$platform = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||||
|
$path = $argv[$i + 1];
|
||||||
|
}
|
||||||
|
if ($arg === '--help' || $arg === '-h') {
|
||||||
|
printUsage();
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow token from environment
|
||||||
|
if ($token === null) {
|
||||||
|
$envToken = getenv('GA_TOKEN');
|
||||||
|
if ($envToken !== false && $envToken !== '') {
|
||||||
|
$token = $envToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($token === null) {
|
||||||
|
$envToken = getenv('GITEA_TOKEN');
|
||||||
|
if ($envToken !== false && $envToken !== '') {
|
||||||
|
$token = $envToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token === null || $apiBase === null) {
|
||||||
|
fwrite(STDERR, "Error: --token and --api-base are required.\n\n");
|
||||||
|
printUsage();
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Platform detection ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($platform === null && $path !== null) {
|
||||||
|
$platform = detectPlatform($path);
|
||||||
|
if ($platform !== null) {
|
||||||
|
echo "Detected platform: {$platform}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($platform === null) {
|
||||||
|
fwrite(STDERR, "Error: could not determine platform. Use --platform or --path.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispatch by platform ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$changed = 0;
|
||||||
|
|
||||||
|
if (in_array($platform, ['dolibarr', 'crm-module'], true)) {
|
||||||
|
$changed = resetDolibarrVersion($apiBase, $token, $branch);
|
||||||
|
} elseif (in_array($platform, ['joomla', 'waas-component'], true)) {
|
||||||
|
echo "Joomla version reset is not yet implemented — skipping.\n";
|
||||||
|
} else {
|
||||||
|
echo "Platform '{$platform}' has no version-reset logic — skipping.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Reset {$changed} file(s) to 'development' on branch '{$branch}'.\n";
|
||||||
|
exit(0);
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Helper functions
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print usage information to stdout.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function printUsage(): void
|
||||||
|
{
|
||||||
|
echo <<<'USAGE'
|
||||||
|
Reset platform version to 'development' on a branch via Gitea API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
php version_reset_dev.php --token TOKEN --api-base URL [options]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--token TOKEN Gitea API token (also reads GA_TOKEN / GITEA_TOKEN env)
|
||||||
|
--api-base URL Gitea API base URL for the repo
|
||||||
|
e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--branch BRANCH Target branch (default: dev)
|
||||||
|
--platform TYPE Platform type: dolibarr, crm-module, joomla, waas-component
|
||||||
|
--path DIR Repo root for auto-detecting platform from manifest.xml
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the platform type from a repo's .mokogitea/manifest.xml file.
|
||||||
|
*
|
||||||
|
* @param string $repoPath Path to the repository root
|
||||||
|
* @return string|null The detected platform, or null if detection fails
|
||||||
|
*/
|
||||||
|
function detectPlatform(string $repoPath): ?string
|
||||||
|
{
|
||||||
|
$root = realpath($repoPath) ?: $repoPath;
|
||||||
|
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||||
|
|
||||||
|
if (!file_exists($manifestXml)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml = @simplexml_load_file($manifestXml);
|
||||||
|
if ($xml === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($xml->governance->platform)) {
|
||||||
|
$platform = (string) $xml->governance->platform;
|
||||||
|
if ($platform !== '') {
|
||||||
|
return $platform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a Gitea API call and return the decoded JSON response.
|
||||||
|
*
|
||||||
|
* @param string $url Full API URL
|
||||||
|
* @param string $token Gitea API token
|
||||||
|
* @param string $method HTTP method (GET, PUT, POST, DELETE)
|
||||||
|
* @param string|null $body JSON request body, or null for bodiless requests
|
||||||
|
* @return array<string, mixed>|null Decoded JSON response, or null on failure
|
||||||
|
*/
|
||||||
|
function giteaApiCall(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
fwrite(STDERR, "Error: curl_init() failed for {$url}\n");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
"Authorization: token {$token}",
|
||||||
|
'Accept: application/json',
|
||||||
|
];
|
||||||
|
if ($body !== null) {
|
||||||
|
$headers[] = 'Content-Type: application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset Dolibarr module version to 'development' on the target branch.
|
||||||
|
*
|
||||||
|
* Searches the repository tree for mod*.class.php files that contain
|
||||||
|
* `extends DolibarrModules`, then replaces `$this->version = '...'`
|
||||||
|
* with `$this->version = 'development'` via the Gitea file contents API.
|
||||||
|
*
|
||||||
|
* @param string $apiBase Gitea API base URL for the repo
|
||||||
|
* @param string $token Gitea API token
|
||||||
|
* @param string $branch Target branch name
|
||||||
|
* @return int Number of files modified
|
||||||
|
*/
|
||||||
|
function resetDolibarrVersion(string $apiBase, string $token, string $branch): int
|
||||||
|
{
|
||||||
|
// Search the repo tree for mod*.class.php files
|
||||||
|
$treeUrl = "{$apiBase}/git/trees/{$branch}?recursive=true";
|
||||||
|
$tree = giteaApiCall($treeUrl, $token);
|
||||||
|
|
||||||
|
if ($tree === null || !isset($tree['tree']) || !is_array($tree['tree'])) {
|
||||||
|
fwrite(STDERR, "Error: could not read repository tree for branch '{$branch}'.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find candidate files: mod*.class.php anywhere in the tree
|
||||||
|
$candidates = [];
|
||||||
|
foreach ($tree['tree'] as $entry) {
|
||||||
|
if (!isset($entry['path']) || !is_string($entry['path'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$basename = basename($entry['path']);
|
||||||
|
if (preg_match('/^mod[A-Za-z0-9_]+\.class\.php$/', $basename)) {
|
||||||
|
$candidates[] = $entry['path'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($candidates)) {
|
||||||
|
echo "No mod*.class.php files found on branch '{$branch}'.\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = 0;
|
||||||
|
|
||||||
|
foreach ($candidates as $filePath) {
|
||||||
|
// GET file contents via API
|
||||||
|
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $filePath)));
|
||||||
|
$fileUrl = "{$apiBase}/contents/{$encodedPath}?ref={$branch}";
|
||||||
|
$fileData = giteaApiCall($fileUrl, $token);
|
||||||
|
|
||||||
|
if ($fileData === null || !isset($fileData['content'])) {
|
||||||
|
echo "Skipping {$filePath}: could not fetch contents.\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 content
|
||||||
|
$rawContent = is_string($fileData['content']) ? $fileData['content'] : '';
|
||||||
|
$content = base64_decode($rawContent, true);
|
||||||
|
if ($content === false) {
|
||||||
|
echo "Skipping {$filePath}: could not decode content.\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify this file extends DolibarrModules
|
||||||
|
if (!str_contains($content, 'extends DolibarrModules')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace $this->version = '...' with $this->version = 'development'
|
||||||
|
$updated = preg_replace(
|
||||||
|
'/(\$this->version\s*=\s*)[\'"][^\'"]*[\'"]/',
|
||||||
|
"\${1}'development'",
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($updated === null || $updated === $content) {
|
||||||
|
echo "Skipping {$filePath}: no version change needed.\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT updated content back via API
|
||||||
|
$sha = $fileData['sha'] ?? '';
|
||||||
|
$putBody = json_encode([
|
||||||
|
'content' => base64_encode($updated),
|
||||||
|
'message' => 'chore(version): reset dev version [skip ci]',
|
||||||
|
'branch' => $branch,
|
||||||
|
'sha' => $sha,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$putUrl = "{$apiBase}/contents/{$encodedPath}";
|
||||||
|
$result = giteaApiCall($putUrl, $token, 'PUT', $putBody);
|
||||||
|
|
||||||
|
if ($result !== null) {
|
||||||
|
echo "Reset: {$filePath} -> \$this->version = 'development'\n";
|
||||||
|
$changed++;
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "Error: failed to update {$filePath} on branch '{$branch}'.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $changed;
|
||||||
|
}
|
||||||
@@ -5,12 +5,18 @@
|
|||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: MokoStandards.CLI
|
* DEFGROUP: moko-platform.CLI
|
||||||
* INGROUP: MokoStandards
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/version_set_platform.php
|
* PATH: /cli/version_set_platform.php
|
||||||
* VERSION: 04.06.00
|
|
||||||
* BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla <version>)
|
* BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla <version>)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php version_set_platform.php --path . --version 04.01.00
|
||||||
|
* php version_set_platform.php --path . --version 04.01.00 --stability alpha
|
||||||
|
*
|
||||||
|
* When --stability is set to anything other than "stable", the suffix is
|
||||||
|
* appended to the version (e.g. 04.01.00-dev, 04.01.00-alpha, 04.01.00-rc).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -18,10 +24,13 @@ declare(strict_types=1);
|
|||||||
$path = '.';
|
$path = '.';
|
||||||
$version = null;
|
$version = null;
|
||||||
$branch = null;
|
$branch = null;
|
||||||
|
$stability = 'stable';
|
||||||
|
|
||||||
foreach ($argv as $i => $arg) {
|
foreach ($argv as $i => $arg) {
|
||||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $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 === '--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
|
// Auto-detect branch from git or GitHub env
|
||||||
@@ -33,22 +42,54 @@ if ($branch === null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($version === 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);
|
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;
|
$root = realpath($path) ?: $path;
|
||||||
|
|
||||||
// Detect platform
|
// Detect platform — check manifest.xml first, then legacy .mokostandards
|
||||||
$platform = '';
|
$platform = '';
|
||||||
$mokoStandards = "{$root}/.github/.mokostandards";
|
|
||||||
if (!file_exists($mokoStandards)) {
|
// New format: .mokogitea/manifest.xml (XML with <platform> tag)
|
||||||
$mokoStandards = "{$root}/.mokostandards";
|
$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);
|
// Legacy: .mokostandards YAML file
|
||||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
if (empty($platform)) {
|
||||||
$platform = trim($m[1], " \t\n\r\"'");
|
$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
|
// Joomla: <version> in XML manifests (top-level + sub-packages)
|
||||||
if ($platform === 'waas-component') {
|
if (in_array($platform, ['waas-component', 'joomla'], true)) {
|
||||||
foreach (glob("{$root}/src/*.xml") ?: glob("{$root}/*.xml") ?: [] as $file) {
|
$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);
|
$content = file_get_contents($file);
|
||||||
if (!str_contains($content, '<extension')) continue;
|
if (!str_contains($content, '<extension')) continue;
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
@@ -102,7 +151,8 @@ if ($platform === 'waas-component') {
|
|||||||
);
|
);
|
||||||
if ($updated !== $content) {
|
if ($updated !== $content) {
|
||||||
file_put_contents($file, $updated);
|
file_put_contents($file, $updated);
|
||||||
echo "Joomla: " . basename($file) . " → {$version}\n";
|
$relPath = str_replace($root . '/', '', $file);
|
||||||
|
echo "Joomla: {$relPath} → {$version}\n";
|
||||||
$changed++;
|
$changed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* FILE INFORMATION
|
||||||
|
* DEFGROUP: moko-platform.CLI
|
||||||
|
* INGROUP: moko-platform
|
||||||
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
* PATH: /cli/wiki_sync.php
|
||||||
|
* VERSION: 01.00.00
|
||||||
|
* BRIEF: Sync select wiki pages from moko-platform to all template repos
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class WikiSync
|
||||||
|
{
|
||||||
|
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||||
|
private string $token = '';
|
||||||
|
private string $org = 'MokoConsulting';
|
||||||
|
private string $sourceRepo = 'moko-platform';
|
||||||
|
private array $targetRepos = [];
|
||||||
|
private array $pages = [];
|
||||||
|
private bool $dryRun = false;
|
||||||
|
private bool $allTemplates = false;
|
||||||
|
|
||||||
|
private int $synced = 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 (empty($this->pages) && !$this->allTemplates) {
|
||||||
|
$this->log('ERROR: --page or --all-standards is required.');
|
||||||
|
$this->printUsage();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover template repos if --all-templates
|
||||||
|
if ($this->allTemplates || empty($this->targetRepos)) {
|
||||||
|
$this->targetRepos = $this->discoverTemplateRepos();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->targetRepos)) {
|
||||||
|
$this->log('No target repos found.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If --all-standards, get all pages that start with uppercase
|
||||||
|
if (empty($this->pages)) {
|
||||||
|
$this->pages = $this->getStandardsPages();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log("Syncing " . count($this->pages) . " page(s) to " . count($this->targetRepos) . " repo(s)");
|
||||||
|
if ($this->dryRun) {
|
||||||
|
$this->log("[DRY RUN] No changes will be made.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->pages as $pageName) {
|
||||||
|
$this->log("\n--- Page: {$pageName} ---");
|
||||||
|
$sourceContent = $this->getWikiPage($this->sourceRepo, $pageName);
|
||||||
|
if ($sourceContent === null) {
|
||||||
|
$this->log(" WARNING: page not found in {$this->sourceRepo}");
|
||||||
|
$this->errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->targetRepos as $repo) {
|
||||||
|
$existing = $this->getWikiPage($repo, $pageName);
|
||||||
|
if ($existing !== null && $existing === $sourceContent) {
|
||||||
|
$this->log(" {$repo}: IDENTICAL (skipped)");
|
||||||
|
$this->skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->dryRun) {
|
||||||
|
$action = $existing !== null ? 'WOULD UPDATE' : 'WOULD CREATE';
|
||||||
|
$this->log(" {$repo}: {$action}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
$ok = $this->updateWikiPage($repo, $pageName, $sourceContent);
|
||||||
|
$this->log(" {$repo}: " . ($ok ? 'UPDATED' : 'ERROR'));
|
||||||
|
$ok ? $this->synced++ : $this->errors++;
|
||||||
|
} else {
|
||||||
|
$ok = $this->createWikiPage($repo, $pageName, $sourceContent);
|
||||||
|
$this->log(" {$repo}: " . ($ok ? 'CREATED' : 'ERROR'));
|
||||||
|
$ok ? $this->created++ : $this->errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log("\nDone: {$this->synced} updated, {$this->created} created, {$this->skipped} skipped, {$this->errors} error(s)");
|
||||||
|
return $this->errors > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function discoverTemplateRepos(): array
|
||||||
|
{
|
||||||
|
$repos = $this->apiGet("/orgs/{$this->org}/repos?limit=100");
|
||||||
|
$templates = [];
|
||||||
|
foreach ($repos as $repo) {
|
||||||
|
if (str_starts_with($repo['name'], 'Template-') && !($repo['archived'] ?? false)) {
|
||||||
|
$templates[] = $repo['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($templates);
|
||||||
|
$this->log("Found template repos: " . implode(', ', $templates));
|
||||||
|
return $templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getStandardsPages(): array
|
||||||
|
{
|
||||||
|
$pages = $this->apiGet("/repos/{$this->org}/{$this->sourceRepo}/wiki/pages");
|
||||||
|
$standards = [];
|
||||||
|
foreach ($pages as $page) {
|
||||||
|
$title = $page['title'] ?? '';
|
||||||
|
// Sync pages that are all-caps with underscores (standards pages)
|
||||||
|
if (preg_match('/^[A-Z][A-Z0-9_-]+$/', $title)) {
|
||||||
|
$standards[] = $title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($standards);
|
||||||
|
$this->log("Found " . count($standards) . " standards pages: " . implode(', ', $standards));
|
||||||
|
return $standards;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getWikiPage(string $repo, string $pageName): ?string
|
||||||
|
{
|
||||||
|
$data = $this->apiGet("/repos/{$this->org}/{$repo}/wiki/page/{$pageName}");
|
||||||
|
if ($data === null || !isset($data['content_base64'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return base64_decode($data['content_base64']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createWikiPage(string $repo, string $pageName, string $content): bool
|
||||||
|
{
|
||||||
|
$payload = json_encode([
|
||||||
|
'title' => $pageName,
|
||||||
|
'content_base64' => base64_encode($content),
|
||||||
|
]);
|
||||||
|
return $this->apiPost("/repos/{$this->org}/{$repo}/wiki/new", $payload) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateWikiPage(string $repo, string $pageName, string $content): bool
|
||||||
|
{
|
||||||
|
$payload = json_encode([
|
||||||
|
'title' => $pageName,
|
||||||
|
'content_base64' => base64_encode($content),
|
||||||
|
]);
|
||||||
|
return $this->apiPatch("/repos/{$this->org}/{$repo}/wiki/page/{$pageName}", $payload) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apiGet(string $endpoint): ?array
|
||||||
|
{
|
||||||
|
$url = "{$this->giteaUrl}/api/v1{$endpoint}";
|
||||||
|
$opts = [
|
||||||
|
'http' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'header' => "Authorization: token {$this->token}\r\nAccept: application/json\r\n",
|
||||||
|
'ignore_errors' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$ctx = stream_context_create($opts);
|
||||||
|
$result = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($result === false) return null;
|
||||||
|
$data = json_decode($result, true);
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apiPost(string $endpoint, string $payload): ?array
|
||||||
|
{
|
||||||
|
return $this->apiWrite('POST', $endpoint, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apiPatch(string $endpoint, string $payload): ?array
|
||||||
|
{
|
||||||
|
return $this->apiWrite('PATCH', $endpoint, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apiWrite(string $method, string $endpoint, string $payload): ?array
|
||||||
|
{
|
||||||
|
$url = "{$this->giteaUrl}/api/v1{$endpoint}";
|
||||||
|
$opts = [
|
||||||
|
'http' => [
|
||||||
|
'method' => $method,
|
||||||
|
'header' => "Authorization: token {$this->token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
|
||||||
|
'content' => $payload,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$ctx = stream_context_create($opts);
|
||||||
|
$result = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($result === false) return null;
|
||||||
|
$data = json_decode($result, true);
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseArgs(): void
|
||||||
|
{
|
||||||
|
global $argv;
|
||||||
|
$args = $argv;
|
||||||
|
for ($i = 1; $i < count($args); $i++) {
|
||||||
|
switch ($args[$i]) {
|
||||||
|
case '--token':
|
||||||
|
$this->token = $args[++$i] ?? '';
|
||||||
|
break;
|
||||||
|
case '--org':
|
||||||
|
$this->org = $args[++$i] ?? '';
|
||||||
|
break;
|
||||||
|
case '--source':
|
||||||
|
$this->sourceRepo = $args[++$i] ?? '';
|
||||||
|
break;
|
||||||
|
case '--target':
|
||||||
|
$this->targetRepos[] = $args[++$i] ?? '';
|
||||||
|
break;
|
||||||
|
case '--page':
|
||||||
|
$this->pages[] = $args[++$i] ?? '';
|
||||||
|
break;
|
||||||
|
case '--all-standards':
|
||||||
|
$this->pages = []; // will be populated from source wiki
|
||||||
|
$this->allTemplates = true;
|
||||||
|
break;
|
||||||
|
case '--all-templates':
|
||||||
|
$this->allTemplates = true;
|
||||||
|
break;
|
||||||
|
case '--dry-run':
|
||||||
|
$this->dryRun = true;
|
||||||
|
break;
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
$this->printUsage();
|
||||||
|
exit(0);
|
||||||
|
default:
|
||||||
|
$this->log("WARNING: Unknown argument: {$args[$i]}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printUsage(): void
|
||||||
|
{
|
||||||
|
$this->log('Usage: wiki_sync.php --token <token> [options]');
|
||||||
|
$this->log('');
|
||||||
|
$this->log('Sync wiki pages from moko-platform to template repos.');
|
||||||
|
$this->log('');
|
||||||
|
$this->log('Options:');
|
||||||
|
$this->log(' --token <token> Gitea API token (required)');
|
||||||
|
$this->log(' --org <org> Organization (default: MokoConsulting)');
|
||||||
|
$this->log(' --source <repo> Source repo (default: moko-platform)');
|
||||||
|
$this->log(' --target <repo> Target repo (can repeat; default: all Template-* repos)');
|
||||||
|
$this->log(' --page <name> Page to sync (can repeat)');
|
||||||
|
$this->log(' --all-standards Sync all UPPERCASE standards pages');
|
||||||
|
$this->log(' --all-templates Target all Template-* repos');
|
||||||
|
$this->log(' --dry-run Show what would be done');
|
||||||
|
$this->log(' --help, -h Show this help');
|
||||||
|
$this->log('');
|
||||||
|
$this->log('Examples:');
|
||||||
|
$this->log(' php wiki_sync.php --token xxx --page MANIFEST_STANDARD --all-templates');
|
||||||
|
$this->log(' php wiki_sync.php --token xxx --all-standards --all-templates --dry-run');
|
||||||
|
$this->log(' php wiki_sync.php --token xxx --page WORKFLOW_STANDARDS --target Template-Joomla');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(string $msg): void
|
||||||
|
{
|
||||||
|
fwrite(STDERR, $msg . "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(new WikiSync())->run();
|
||||||
+3
-4
@@ -2,7 +2,7 @@
|
|||||||
"name": "mokoconsulting-tech/enterprise",
|
"name": "mokoconsulting-tech/enterprise",
|
||||||
"description": "MokoStandards Enterprise API \u2014 PHP implementation",
|
"description": "MokoStandards Enterprise API \u2014 PHP implementation",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"version": "04.05.00",
|
"version": "09.01.00",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"guzzlehttp/guzzle": "^7.8",
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
"mokoconsulting-tech/enterprise": "dev-version/04",
|
|
||||||
"monolog/monolog": "^3.5",
|
"monolog/monolog": "^3.5",
|
||||||
"php": ">=8.1",
|
"php": ">=8.1",
|
||||||
"phpseclib/phpseclib": "^3.0",
|
"phpseclib/phpseclib": "^3.0",
|
||||||
@@ -74,8 +73,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "phpunit",
|
"test": "phpunit",
|
||||||
"phpcs": "phpcs --standard=phpcs.xml api/",
|
"phpcs": "phpcs --standard=phpcs.xml lib/ validate/ automation/",
|
||||||
"phpstan": "phpstan analyse -c phpstan.neon api/",
|
"phpstan": "phpstan analyse -c phpstan.neon lib/ validate/ automation/",
|
||||||
"psalm": "psalm --config=psalm.xml",
|
"psalm": "psalm --config=psalm.xml",
|
||||||
"check": [
|
"check": [
|
||||||
"@phpcs",
|
"@phpcs",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,306 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
* (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
|
|
||||||
*/
|
|
||||||
|
|
||||||
locals {
|
|
||||||
repository_structure = {
|
|
||||||
metadata = {
|
|
||||||
name = "Dolibarr Platform"
|
|
||||||
description = "Full Dolibarr ERP/CRM installation — htdocs/ root, not a module"
|
|
||||||
repository_type = "crm-platform"
|
|
||||||
platform = "dolibarr"
|
|
||||||
last_updated = "2026-03-31T00:00:00Z"
|
|
||||||
maintainer = "Moko Consulting"
|
|
||||||
version = "04.05.00"
|
|
||||||
schema_version = "1.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
root_files = [
|
|
||||||
{
|
|
||||||
name = "README.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Developer-focused documentation"
|
|
||||||
required = true
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CONTRIBUTING.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Contribution guidelines"
|
|
||||||
required = true
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/docs/required/template-CONTRIBUTING.md"
|
|
||||||
audience = "contributor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "LICENSE"
|
|
||||||
extension = ""
|
|
||||||
description = "GPL-3.0-or-later license file"
|
|
||||||
required = true
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/docs/required/LICENSE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "composer.json"
|
|
||||||
extension = "json"
|
|
||||||
description = "Composer package definition"
|
|
||||||
required = true
|
|
||||||
always_overwrite = false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "phpstan.neon"
|
|
||||||
extension = "neon"
|
|
||||||
description = "PHPStan static analysis configuration"
|
|
||||||
required = true
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/configs/phpstan.neon"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "Makefile"
|
|
||||||
extension = ""
|
|
||||||
description = "Build automation targets"
|
|
||||||
required = true
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/configs/Makefile"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "src/.ftpignore"
|
|
||||||
extension = ""
|
|
||||||
description = "Files excluded from SFTP deployment"
|
|
||||||
required = true
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/configs/ftp_ignore"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".mokostandards"
|
|
||||||
extension = ""
|
|
||||||
description = "MokoStandards platform identifier"
|
|
||||||
required = true
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/configs/mokostandards.yml.template"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
directories = [
|
|
||||||
{
|
|
||||||
name = "htdocs"
|
|
||||||
path = "htdocs"
|
|
||||||
description = "Dolibarr web root — entire platform"
|
|
||||||
required = true
|
|
||||||
purpose = "Contains the full Dolibarr installation including core, custom modules, and themes"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "docs"
|
|
||||||
path = "docs"
|
|
||||||
description = "Developer and technical documentation"
|
|
||||||
required = true
|
|
||||||
purpose = "Contains technical documentation"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "update-server.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Dolibarr update server (update.txt) documentation"
|
|
||||||
required = true
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/docs/required/template-update-server-dolibarr.md"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".github"
|
|
||||||
path = ".github"
|
|
||||||
description = "GitHub configuration"
|
|
||||||
required = true
|
|
||||||
purpose = "Contains GitHub Actions workflows and configuration"
|
|
||||||
subdirectories = [
|
|
||||||
{
|
|
||||||
name = "workflows"
|
|
||||||
path = ".github/workflows"
|
|
||||||
description = "GitHub Actions workflows"
|
|
||||||
requirement_status = "required"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
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"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/enterprise-firewall-setup.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-dev.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment to dev server (htdocs/)"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/deploy-dev.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-demo.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment to demo server"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/deploy-demo.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-rs.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment to release staging server"
|
|
||||||
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"
|
|
||||||
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 minor version"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/auto-release.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "repository-cleanup.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Scheduled cleanup: retired workflows, stale branches"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/repository-cleanup.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "auto-dev-issue.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Auto-create tracking issue on dev/rc branch creation"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/auto-dev-issue.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "repo_health.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Dolibarr platform health checks (shared guardrails, no module-specific checks)"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/dolibarr/repo_health.yml.template"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "ISSUE_TEMPLATE"
|
|
||||||
path = ".github/ISSUE_TEMPLATE"
|
|
||||||
description = "GitHub issue templates"
|
|
||||||
requirement_status = "required"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "config.yml"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/config.yml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "adr.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/adr.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "bug_report.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/bug_report.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "documentation.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/documentation.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "enterprise_support.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/enterprise_support.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "feature_request.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/feature_request.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "firewall-request.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/firewall-request.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "question.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/question.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "request-license.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/request-license.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "rfc.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/rfc.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "security.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/security.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "dolibarr_issue.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/dolibarr_issue.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "dolibarr_module_id_request.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/dolibarr_module_id_request.md"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output "crm_platform_structure" {
|
|
||||||
description = "Dolibarr Platform repository structure definition"
|
|
||||||
value = local.repository_structure
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
{
|
|
||||||
"schemaVersion": "1.0",
|
|
||||||
"metadata": {
|
|
||||||
"name": "Default Repository Structure",
|
|
||||||
"description": "Default repository structure applicable to all repository types with minimal requirements",
|
|
||||||
"repositoryType": "library",
|
|
||||||
"platform": "multi-platform",
|
|
||||||
"lastUpdated": "2026-01-16T00:00:00Z",
|
|
||||||
"maintainer": "Moko Consulting"
|
|
||||||
},
|
|
||||||
"structure": {
|
|
||||||
"rootFiles": [
|
|
||||||
{
|
|
||||||
"name": "README.md",
|
|
||||||
"extension": "md",
|
|
||||||
"description": "Project overview and documentation",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"audience": "general",
|
|
||||||
"template": "templates/docs/required/template-README.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "LICENSE",
|
|
||||||
"extension": "",
|
|
||||||
"description": "License file (GPL-3.0-or-later)",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"audience": "general",
|
|
||||||
"template": "templates/licenses/GPL-3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CHANGELOG.md",
|
|
||||||
"extension": "md",
|
|
||||||
"description": "Version history and changes",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"audience": "general",
|
|
||||||
"template": "templates/docs/required/template-CHANGELOG.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CONTRIBUTING.md",
|
|
||||||
"extension": "md",
|
|
||||||
"description": "Contribution guidelines",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"audience": "contributor",
|
|
||||||
"template": "templates/docs/required/template-CONTRIBUTING.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SECURITY.md",
|
|
||||||
"extension": "md",
|
|
||||||
"description": "Security policy and vulnerability reporting",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"audience": "general",
|
|
||||||
"template": "templates/docs/required/template-SECURITY.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CODE_OF_CONDUCT.md",
|
|
||||||
"extension": "md",
|
|
||||||
"description": "Community code of conduct",
|
|
||||||
"requirementStatus": "suggested",
|
|
||||||
"audience": "contributor",
|
|
||||||
"template": "templates/docs/extra/template-CODE_OF_CONDUCT.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": ".gitignore",
|
|
||||||
"extension": "gitignore",
|
|
||||||
"description": "Git ignore patterns",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"alwaysOverwrite": false,
|
|
||||||
"audience": "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": ".gitattributes",
|
|
||||||
"extension": "gitattributes",
|
|
||||||
"description": "Git attributes configuration",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"audience": "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": ".editorconfig",
|
|
||||||
"extension": "editorconfig",
|
|
||||||
"description": "Editor configuration for consistent coding style",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"alwaysOverwrite": false,
|
|
||||||
"audience": "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Makefile",
|
|
||||||
"description": "Build automation",
|
|
||||||
"requirementStatus": "suggested",
|
|
||||||
"audience": "developer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"directories": [
|
|
||||||
{
|
|
||||||
"name": "docs",
|
|
||||||
"path": "docs",
|
|
||||||
"description": "Documentation directory",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"purpose": "Contains comprehensive project documentation",
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "index.md",
|
|
||||||
"extension": "md",
|
|
||||||
"description": "Documentation index",
|
|
||||||
"requirementStatus": "suggested"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "scripts",
|
|
||||||
"path": "scripts",
|
|
||||||
"description": "Build and automation scripts",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"purpose": "Contains scripts for building, testing, and deploying"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "src",
|
|
||||||
"path": "src",
|
|
||||||
"description": "Source code directory",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"purpose": "Contains application source code"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "tests",
|
|
||||||
"path": "tests",
|
|
||||||
"description": "Test files",
|
|
||||||
"requirementStatus": "suggested",
|
|
||||||
"purpose": "Contains unit tests, integration tests, and test fixtures",
|
|
||||||
"subdirectories": [
|
|
||||||
{
|
|
||||||
"name": "unit",
|
|
||||||
"path": "tests/unit",
|
|
||||||
"description": "Unit tests",
|
|
||||||
"requirementStatus": "suggested"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "integration",
|
|
||||||
"path": "tests/integration",
|
|
||||||
"description": "Integration tests",
|
|
||||||
"requirementStatus": "optional"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": ".github",
|
|
||||||
"path": ".github",
|
|
||||||
"description": "Gitea/GitHub Actions configuration (Gitea reads .github/workflows natively)",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"purpose": "Contains CI/CD workflows and repository configuration. Gitea is the primary platform; GitHub is backup only.",
|
|
||||||
"subdirectories": [
|
|
||||||
{
|
|
||||||
"name": "workflows",
|
|
||||||
"path": ".github/workflows",
|
|
||||||
"description": "CI/CD workflows (Gitea-primary, GitHub-compatible)",
|
|
||||||
"requirementStatus": "required",
|
|
||||||
"requiredFiles": [
|
|
||||||
"auto-assign.yml",
|
|
||||||
"auto-dev-issue.yml",
|
|
||||||
"auto-release.yml",
|
|
||||||
"branch-freeze.yml",
|
|
||||||
"changelog-validation.yml",
|
|
||||||
"repository-cleanup.yml",
|
|
||||||
"sync-version-on-merge.yml"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "node_modules",
|
|
||||||
"path": "node_modules",
|
|
||||||
"description": "Node.js dependencies (generated)",
|
|
||||||
"requirementStatus": "not-allowed",
|
|
||||||
"purpose": "Generated directory that should not be committed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vendor",
|
|
||||||
"path": "vendor",
|
|
||||||
"description": "PHP dependencies (generated)",
|
|
||||||
"requirementStatus": "not-allowed",
|
|
||||||
"purpose": "Generated directory that should not be committed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "build",
|
|
||||||
"path": "build",
|
|
||||||
"description": "Build artifacts (generated)",
|
|
||||||
"requirementStatus": "not-allowed",
|
|
||||||
"purpose": "Generated directory that should not be committed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dist",
|
|
||||||
"path": "dist",
|
|
||||||
"description": "Distribution files (generated)",
|
|
||||||
"requirementStatus": "not-allowed",
|
|
||||||
"purpose": "Generated directory that should not be committed"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,640 +0,0 @@
|
|||||||
/**
|
|
||||||
* Default Repository Structure Definition
|
|
||||||
* Default repository structure applicable to all repository types with minimal requirements
|
|
||||||
*
|
|
||||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
* Version: 04.05.00
|
|
||||||
* Schema Version: 1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
locals {
|
|
||||||
repository_structure = {
|
|
||||||
metadata = {
|
|
||||||
name = "Default Repository Structure"
|
|
||||||
description = "Default repository structure applicable to all repository types with minimal requirements"
|
|
||||||
repository_type = "library"
|
|
||||||
platform = "multi-platform"
|
|
||||||
last_updated = "2026-01-16T00:00:00Z"
|
|
||||||
maintainer = "Moko Consulting"
|
|
||||||
version = "04.05.00"
|
|
||||||
schema_version = "1.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
root_files = [
|
|
||||||
{
|
|
||||||
name = "README.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Project overview and documentation"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-README.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "README.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/required/template-README.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "LICENSE"
|
|
||||||
extension = ""
|
|
||||||
description = "License file (GPL-3.0-or-later)"
|
|
||||||
requirement_status = "required"
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/licenses"
|
|
||||||
source_filename = "GPL-3.0"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "LICENSE"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/licenses/GPL-3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CHANGELOG.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Version history and changes"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-CHANGELOG.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "CHANGELOG.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/required/template-CHANGELOG.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CONTRIBUTING.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Contribution guidelines"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "contributor"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-CONTRIBUTING.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "CONTRIBUTING.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/required/template-CONTRIBUTING.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "SECURITY.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Security policy and vulnerability reporting"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-SECURITY.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "SECURITY.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/required/template-SECURITY.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CODE_OF_CONDUCT.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Community code of conduct"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "contributor"
|
|
||||||
source_path = "templates/docs/extra"
|
|
||||||
source_filename = "template-CODE_OF_CONDUCT.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "CODE_OF_CONDUCT.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/extra/template-CODE_OF_CONDUCT.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "ROADMAP.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Project roadmap with version goals and milestones"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/extra"
|
|
||||||
source_filename = "template-ROADMAP.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "ROADMAP.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/extra/template-ROADMAP.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "GOVERNANCE.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Project governance model and decision-making process"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
always_overwrite = false
|
|
||||||
protected = true
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/extra"
|
|
||||||
source_filename = "template-GOVERNANCE.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "GOVERNANCE.md"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/docs/extra/template-GOVERNANCE.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".gitignore"
|
|
||||||
extension = "gitignore"
|
|
||||||
description = "Git ignore patterns"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".gitattributes"
|
|
||||||
extension = "gitattributes"
|
|
||||||
description = "Git attributes configuration"
|
|
||||||
requirement_status = "required"
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".editorconfig"
|
|
||||||
extension = "editorconfig"
|
|
||||||
description = "Editor configuration for consistent coding style"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = false
|
|
||||||
audience = "developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "Makefile"
|
|
||||||
description = "Build automation"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
audience = "developer"
|
|
||||||
source_path = "templates/makefiles"
|
|
||||||
source_filename = "Makefile.generic.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "."
|
|
||||||
destination_filename = "Makefile"
|
|
||||||
create_path = false
|
|
||||||
template = "templates/makefiles/Makefile.generic.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "composer.json"
|
|
||||||
extension = "json"
|
|
||||||
description = "Composer manifest — requires mokoconsulting-tech/enterprise for CLI scripts and tooling"
|
|
||||||
required = true
|
|
||||||
always_overwrite = false
|
|
||||||
audience = "developer"
|
|
||||||
template = "templates/configs/composer.generic.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
directories = [
|
|
||||||
{
|
|
||||||
name = "docs"
|
|
||||||
path = "docs"
|
|
||||||
description = "Documentation directory"
|
|
||||||
requirement_status = "required"
|
|
||||||
purpose = "Contains comprehensive project documentation"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "index.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Documentation index"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
template = "templates/docs/index.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "INSTALLATION.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Installation and setup instructions"
|
|
||||||
requirement_status = "required"
|
|
||||||
audience = "general"
|
|
||||||
source_path = "templates/docs/required"
|
|
||||||
source_filename = "template-INSTALLATION.md"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = "docs"
|
|
||||||
destination_filename = "INSTALLATION.md"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/docs/required/template-INSTALLATION.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "API.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "API documentation"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "ARCHITECTURE.md"
|
|
||||||
extension = "md"
|
|
||||||
description = "Architecture documentation"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "scripts"
|
|
||||||
path = "scripts"
|
|
||||||
description = "Repo-specific scripts — not managed by MokoStandards sync"
|
|
||||||
required = false
|
|
||||||
purpose = "Optional directory for repo-specific build helpers and one-off scripts. MokoStandards tools are installed via Composer (mokoconsulting-tech/enterprise) and called through vendor/bin/."
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "MokoStandards.override.xml"
|
|
||||||
extension = "xml"
|
|
||||||
description = "MokoStandards sync override configuration"
|
|
||||||
requirement_status = "optional"
|
|
||||||
always_overwrite = false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "src"
|
|
||||||
path = "src"
|
|
||||||
description = "Source code directory"
|
|
||||||
requirement_status = "required"
|
|
||||||
purpose = "Contains application source code"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "tests"
|
|
||||||
path = "tests"
|
|
||||||
description = "Test files"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
purpose = "Contains unit tests, integration tests, and test fixtures"
|
|
||||||
subdirectories = [
|
|
||||||
{
|
|
||||||
name = "unit"
|
|
||||||
path = "tests/unit"
|
|
||||||
description = "Unit tests"
|
|
||||||
requirement_status = "suggested"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "integration"
|
|
||||||
path = "tests/integration"
|
|
||||||
description = "Integration tests"
|
|
||||||
requirement_status = "optional"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = ".github"
|
|
||||||
path = ".github"
|
|
||||||
description = "GitHub-specific configuration"
|
|
||||||
requirement_status = "required"
|
|
||||||
purpose = "Contains GitHub Actions workflows and configuration"
|
|
||||||
subdirectories = [
|
|
||||||
{
|
|
||||||
name = "workflows"
|
|
||||||
path = ".github/workflows"
|
|
||||||
description = "GitHub Actions workflows"
|
|
||||||
requirement_status = "required"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "test.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Comprehensive testing workflow"
|
|
||||||
requirement_status = "optional"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = "templates/workflows/generic"
|
|
||||||
source_filename = "test.yml.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "test.yml"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/workflows/generic/test.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "code-quality.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Code quality and linting workflow"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = "templates/workflows/generic"
|
|
||||||
source_filename = "code-quality.yml.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "code-quality.yml"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/workflows/generic/code-quality.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "codeql-analysis.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "CodeQL security analysis workflow"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = "templates/workflows/generic"
|
|
||||||
source_filename = "codeql-analysis.yml.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "codeql-analysis.yml"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/workflows/generic/codeql-analysis.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Deployment workflow"
|
|
||||||
requirement_status = "optional"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = "templates/workflows/generic"
|
|
||||||
source_filename = "deploy.yml.template"
|
|
||||||
source_type = "template"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "deploy.yml"
|
|
||||||
create_path = true
|
|
||||||
template = "templates/workflows/generic/deploy.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "release-cycle.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Release management workflow with automated release flow"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = ".github/workflows"
|
|
||||||
source_filename = "release-cycle.yml"
|
|
||||||
source_type = "copy"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "release-cycle.yml"
|
|
||||||
create_path = true
|
|
||||||
template = ".github/workflows/release-cycle.yml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "standards-compliance.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "MokoStandards compliance validation"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
source_path = ".github/workflows"
|
|
||||||
source_filename = "standards-compliance.yml"
|
|
||||||
source_type = "copy"
|
|
||||||
destination_path = ".github/workflows"
|
|
||||||
destination_filename = "standards-compliance.yml"
|
|
||||||
create_path = true
|
|
||||||
template = ".github/workflows/standards-compliance.yml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "enterprise-firewall-setup.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Enterprise firewall configuration for trusted domain access"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/enterprise-firewall-setup.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-dev.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment of src/ to the development server"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/deploy-dev.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-demo.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment of src/ to the demo server on merge to main"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/deploy-demo.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "deploy-rs.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "SFTP deployment of src/ to the release staging server on merge to main"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/deploy-rs.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "sync-version-on-merge.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Auto-bump patch version on merge and propagate to all file headers"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/sync-version-on-merge.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "auto-release.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Auto-create GitHub Release on push to main with version from README.md"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/auto-release.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "repository-cleanup.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Scheduled cleanup: delete retired workflows, stale branches, old workflow runs"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/repository-cleanup.yml.template"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "auto-dev-issue.yml"
|
|
||||||
extension = "yml"
|
|
||||||
description = "Auto-create tracking issue when a dev/** branch is pushed"
|
|
||||||
requirement_status = "required"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/workflows/shared/auto-dev-issue.yml.template"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "ISSUE_TEMPLATE"
|
|
||||||
path = ".github/ISSUE_TEMPLATE"
|
|
||||||
description = "GitHub issue templates synced from MokoStandards"
|
|
||||||
requirement_status = "required"
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
name = "config.yml"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/config.yml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "adr.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/adr.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "bug_report.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/bug_report.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "documentation.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/documentation.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "enterprise_support.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/enterprise_support.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "feature_request.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/feature_request.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "firewall-request.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/firewall-request.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "question.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/question.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "request-license.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/request-license.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "rfc.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/rfc.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "security.md"
|
|
||||||
always_overwrite = true
|
|
||||||
template = "templates/github/ISSUE_TEMPLATE/security.md"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "node_modules"
|
|
||||||
path = "node_modules"
|
|
||||||
description = "Node.js dependencies (generated)"
|
|
||||||
requirement_status = "not-allowed"
|
|
||||||
purpose = "Generated directory that should not be committed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "vendor"
|
|
||||||
path = "vendor"
|
|
||||||
description = "PHP dependencies (generated)"
|
|
||||||
requirement_status = "not-allowed"
|
|
||||||
purpose = "Generated directory that should not be committed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "build"
|
|
||||||
path = "build"
|
|
||||||
description = "Build artifacts (generated)"
|
|
||||||
requirement_status = "not-allowed"
|
|
||||||
purpose = "Generated directory that should not be committed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "dist"
|
|
||||||
path = "dist"
|
|
||||||
description = "Distribution files (generated)"
|
|
||||||
requirement_status = "not-allowed"
|
|
||||||
purpose = "Generated directory that should not be committed"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
repository_requirements = {
|
|
||||||
secrets = [
|
|
||||||
{
|
|
||||||
name = "GH_TOKEN"
|
|
||||||
description = "Org-level GitHub PAT — configure in org Actions secrets"
|
|
||||||
required = true
|
|
||||||
scope = "organisation"
|
|
||||||
used_in = "GitHub Actions workflows"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "CODECOV_TOKEN"
|
|
||||||
description = "Codecov upload token for code coverage reporting"
|
|
||||||
required = false
|
|
||||||
scope = "repository"
|
|
||||||
used_in = "CI workflow code coverage step"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
variables = [
|
|
||||||
{
|
|
||||||
name = "NODE_VERSION"
|
|
||||||
description = "Node.js version for CI/CD"
|
|
||||||
default_value = "18"
|
|
||||||
required = false
|
|
||||||
scope = "repository"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name = "PYTHON_VERSION"
|
|
||||||
description = "Python version for CI/CD"
|
|
||||||
default_value = "3.9"
|
|
||||||
required = false
|
|
||||||
scope = "repository"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
branch_protections = [
|
|
||||||
{
|
|
||||||
branch_pattern = "main"
|
|
||||||
require_pull_request = true
|
|
||||||
required_approvals = 1
|
|
||||||
require_code_owner_review = false
|
|
||||||
dismiss_stale_reviews = true
|
|
||||||
require_status_checks = true
|
|
||||||
required_status_checks = ["ci", "code-quality"]
|
|
||||||
enforce_admins = false
|
|
||||||
restrict_pushes = true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user