Compare commits
383 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01c2491ca7 | |||
| 80be62026f | |||
| 9d5d60fadf | |||
| a0a9b4c204 | |||
| c6ecee1917 | |||
| b3aa838da6 | |||
| 4c62eac923 | |||
| cb64d7371d | |||
| 312ba8072f | |||
| 5b378b564b | |||
| d6a1e77453 | |||
| 2062575736 | |||
| 06d9499b39 | |||
| 39177bf78b | |||
| 97cc0acdf3 | |||
| fcacd01af9 | |||
| bab1187da3 | |||
| 940caa3d2d | |||
| b61a1eff6d | |||
| a49fe2add8 | |||
| eec803edac | |||
| cf5400a047 | |||
| 436a3b1029 | |||
| 9b07bfa167 | |||
| f7a0c3672d | |||
| d2139df514 | |||
| 159b71b1bd | |||
| e1b0fafd70 | |||
| 64e0aec932 | |||
| 52b1f33f3e | |||
| 9e55617f0f | |||
| 7228bb9744 | |||
| 20922e7a1c | |||
| 4579cf684b | |||
| f193d62652 | |||
| 7b0aa534ed | |||
| 416725c212 | |||
| a4e6bdd2af | |||
| b6b212e7d2 | |||
| a984b03e07 | |||
| 6f92653ed7 | |||
| 53da1d7457 | |||
| 6ec75f2b5d | |||
| 07a4841dec | |||
| fff64be76d | |||
| f01d7ba140 | |||
| 0b691be220 | |||
| d9c8752081 | |||
| 9373459301 | |||
| d3494e1f36 | |||
| 978e1774aa | |||
| bbf9ef3310 | |||
| 010a467f5c | |||
| 8cb83668e1 | |||
| 01e6778664 | |||
| 2b5734207c | |||
| 33979440b4 | |||
| 983e56fc16 | |||
| 54b27702e9 | |||
| 67ee1d8282 | |||
| 2c86454989 | |||
| 0856361ebc | |||
| 802bab7777 | |||
| f4ed92c0c3 | |||
| 0ee3b181a5 | |||
| 3226a75086 | |||
| d4ac99c7d9 | |||
| e65ff7e422 | |||
| 998e2f0622 | |||
| e0a7348e2c | |||
| a47d229cbb | |||
| 693ab27fe7 | |||
| 93cf3ff81a | |||
| 694947586f | |||
| f2c1ce0764 | |||
| 20d085f91c | |||
| fc359ec5a5 | |||
| 63e76c27fd | |||
| 87853baf75 | |||
| f35ccb197a | |||
| 3e19ca7408 | |||
| 65d156e15b | |||
| d38766d0b8 | |||
| c3bb1a1295 | |||
| d6f0e1fe0c | |||
| a1ba923155 | |||
| 60b2ee2436 | |||
| 3efcba855d | |||
| 2614e6d930 | |||
| 71bf7c78b7 | |||
| 3a29a94920 | |||
| abdc861dfb | |||
| d42f23dae9 | |||
| 63613aa48c | |||
| f33e0f2701 | |||
| fae7614c29 | |||
| 5b4d5e4d42 | |||
| 0e7a022478 | |||
| da96e87969 | |||
| af3e53b734 | |||
| d9b14431e6 | |||
| e8d9755a2d | |||
| ea347ea41b | |||
| 6e28f9c89a | |||
| 785bfca673 | |||
| eef5d54774 | |||
| 1d99daf4a5 | |||
| 81eaf0d4e3 | |||
| fb376ae368 | |||
| 5bd15387c1 | |||
| 6594bb6239 | |||
| f2930c401f | |||
| f2f91f2f2c | |||
| 649b5c1f23 | |||
| 2923918e0b | |||
| 45e57510f4 | |||
| f9aa1b91ce | |||
| 92491d5559 | |||
| d9db104dd7 | |||
| 660d65aee4 | |||
| 22d8965be1 | |||
| 4d104b8dc7 | |||
| 7b4f03d1f2 | |||
| d201fa0384 | |||
| a1a722c267 | |||
| 739b9f974a | |||
| f65afd370c | |||
| 92609bbdf3 | |||
| 9bae409466 | |||
| ccd9e3c0e4 | |||
| cd780ce8a3 | |||
| 687fdb9ed7 | |||
| 20b56b9b00 | |||
| afdd7b4fe8 | |||
| e65ef8a140 | |||
| 7800ca140f | |||
| f269dfd441 | |||
| 7af5c44406 | |||
| f807f985c8 | |||
| 1d6854b4be | |||
| 147bcc8630 | |||
| c4b1983e9e | |||
| ad3d46180b | |||
| 5dfcf926be | |||
| 50ee63ef80 | |||
| 0b6a72a6ce | |||
| 113c73430c | |||
| 2426b5d85e | |||
| ec945dab73 | |||
| 6371c7240c | |||
| 2810caa810 | |||
| 39e71eb198 | |||
| bba336ff86 | |||
| 4efce6b8ef | |||
| ba8d23d862 | |||
| e4534a62f1 | |||
| 7d497dfdc1 | |||
| 3bf89ed10d | |||
| dda39e64ae | |||
| 6a457d2924 | |||
| 62f72ae215 | |||
| 350a365a84 | |||
| 17cd29c663 | |||
| b308de6bf2 | |||
| 8f7f50f359 | |||
| 06d846c38a | |||
| 302329ad77 | |||
| 09f80ccbe6 | |||
| 396a3e8115 | |||
| 0c167080f2 | |||
| cc4d1c4600 | |||
| 528e6568fc | |||
| 21325b79fd | |||
| 3efacc344f | |||
| 90b2479e63 | |||
| 54f24b364b | |||
| 8ad94c071b | |||
| d4512ae32b | |||
| 5bd1310685 | |||
| 121c4628a3 | |||
| b0776f17c4 | |||
| 972f443865 | |||
| f06adc08eb | |||
| 63851f4119 | |||
| cc581fd0f8 | |||
| 82e3049187 | |||
| 67f2ad7ab0 | |||
| cd188a46ab | |||
| 27fbc090f5 | |||
| 79ebcea9c4 | |||
| 59c29735dc | |||
| b639f72ffd | |||
| d263faeea9 | |||
| 525412e408 | |||
| 6e69768e4f | |||
| c8c114a24c | |||
| e9f13c43f6 | |||
| a5a46a56a8 | |||
| cb8ccf061d | |||
| 238e1e4fbf | |||
| 81800d4159 | |||
| 381446b8ba | |||
| dcf3c75dfb | |||
| c62a1d0a69 | |||
| e1a73cc9de | |||
| db578ad249 | |||
| 3f305d0c60 | |||
| 262c02a234 | |||
| 91a162f922 | |||
| 27a2d2b387 | |||
| 5c0c3ea9b6 | |||
| f6d4680d23 | |||
| 633c6d4b68 | |||
| 99b42e844a | |||
| c38d20d805 | |||
| ae015ee0df | |||
| b669f6c768 | |||
| 65a091c90c | |||
| cec71a88f2 | |||
| 7c5fbe1626 | |||
| 45c78e1f5f | |||
| eacfc432d7 | |||
| ee1c8a2262 | |||
| f910b99f4d | |||
| 5a32a540be | |||
| 1e9b0a5856 | |||
| 273e8b4649 | |||
| 81b26cfdd9 | |||
| b7d48d21bf | |||
| 11d8e42708 | |||
| 1b0eb2b1b9 | |||
| 4f58ec9d1d | |||
| 6d9a832a27 | |||
| ad6660f898 | |||
| bd907f55f7 | |||
| fde1fa0356 | |||
| 86e35fb5e7 | |||
| 8052fe9130 | |||
| 9adc34357c | |||
| 16fa16469e | |||
| 2495f5022a | |||
| 13e5f07951 | |||
| 5dd2656b6f | |||
| 357685e463 | |||
| 45dea64d01 | |||
| 4886090575 | |||
| c312ae2207 | |||
| 25c6778871 | |||
| 3a366e7956 | |||
| 05bcbd8169 | |||
| 939d2a5e6c | |||
| 61ba7f2cd0 | |||
| 2fa69c6a2f | |||
| 6da271b7b2 | |||
| 1092de762f | |||
| 8fe9497c85 | |||
| 61edf950e6 | |||
| cdbe8712df | |||
| 4340aa8dd6 | |||
| 0028e72e8d | |||
| 0e281bd7f9 | |||
| 1054935fcb | |||
| bf7322e6c6 | |||
| f59222ab2b | |||
| fb81373757 | |||
| 3de8b79ece | |||
| 44e86bb581 | |||
| b05cd3f92a | |||
| 16a049c52f | |||
| c5645b3c29 | |||
| 6ff8870a8d | |||
| 6d2c36b4a4 | |||
| e043a4a299 | |||
| 93561db0a4 | |||
| 05ff4a4f6a | |||
| 150742a1e9 | |||
| ef248a72cb | |||
| 1b16556c9b | |||
| 5a44d897a5 | |||
| a704fc0b78 | |||
| 09f3519d3f | |||
| 6f49ba61ff | |||
| de2f50c6e8 | |||
| 3b790f750e | |||
| 585aa40d32 | |||
| ef5684a6d4 | |||
| 9008de9c34 | |||
| 009e8d25b9 | |||
| c53b2f124c | |||
| d5a3540e51 | |||
| 54ed86afde | |||
| a80d7bcbb9 | |||
| fe3b4bc514 | |||
| fbf076d5b6 | |||
| cb41b1320b | |||
| a427c3ca7a | |||
| 1608ec4e49 | |||
| f45630d6d1 | |||
| ec63568a85 | |||
| 8d20662314 | |||
| f96b55702d | |||
| 579bfe5ec5 | |||
| dbab7d9e5a | |||
| 30a673533d | |||
| d0c4e65e0a | |||
| f469e127ed | |||
| 5ea66914e3 | |||
| e4205479bf | |||
| 243fd98feb | |||
| 25650540a4 | |||
| 7b50031454 | |||
| 25bba25015 | |||
| a17e531285 | |||
| bb79fc7cbc | |||
| 969db3fd04 | |||
| 981cb98856 | |||
| feac17127a | |||
| 2600c96ad4 | |||
| 0fd900d324 | |||
| 2d57f72d52 | |||
| 37c3a288f8 | |||
| fb58dde90d | |||
| 9abf7f1e74 | |||
| 74494434e7 | |||
| 6f5e399f70 | |||
| ec90eb227a | |||
| 432271d485 | |||
| b976170ff6 | |||
| 1675501236 | |||
| 9e015bc6bb | |||
| 5e22308bce | |||
| 5ce0fc8bea | |||
| 8487cc45d6 | |||
| 32431478e5 | |||
| 29a80ce9fd | |||
| 5fa29cb9cd | |||
| 9b930883be | |||
| f5e006ab65 | |||
| 1f7870f283 | |||
| 3a17feec0d | |||
| 5d52c30e21 | |||
| 4395f3a21c | |||
| 235670925b | |||
| dc755822cc | |||
| 0db469fe82 | |||
| cf28c4b31e | |||
| c1b7eaa2a7 | |||
| 41234d4673 | |||
| cbe3a66085 | |||
| 5cc3bbf870 | |||
| 2c2876cadb | |||
| 734b4fac2d | |||
| 91e6952fb0 | |||
| 54c107afb5 | |||
| 98288fb3d8 | |||
| 2f4e0a3e79 | |||
| 6ae0df9465 | |||
| f53a055f0a | |||
| 763a0a1965 | |||
| cb326a3ecf | |||
| efd9d3b838 | |||
| cd7e38b663 | |||
| b0824ddd9f | |||
| 222aa412be | |||
| f85a85d3f9 | |||
| 3aec50c448 | |||
| f66d6c3650 | |||
| dc78bbbae9 | |||
| 08256cc244 | |||
| ad0e44285c | |||
| 5288f84399 | |||
| 94a3af70f1 | |||
| 963dd997a0 | |||
| 79150f90ee | |||
| 588dd2f290 | |||
| d4b6a5e77f | |||
| eea794831c | |||
| 4d9d280632 | |||
| 07944de0dc | |||
| ee12bf98c2 | |||
| 2c828b2a10 | |||
| 6a8af3ec92 | |||
| 78d54f13d9 |
@@ -1,20 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: MokoStandards.Templates.Config
|
||||
# INGROUP: MokoStandards.Templates
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/configs/moko-standards.yml
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: Governance attachment template — synced to .mokostandards in every governed repository
|
||||
# NOTE: Tokens replaced at sync time: mokoconsulting-tech, MokoJoomTOS, waas-component, 04.04.00
|
||||
#
|
||||
# This file is managed automatically by MokoStandards bulk sync.
|
||||
# Do not edit manually — changes will be overwritten on the next sync.
|
||||
# To update governance settings, open a PR in MokoStandards instead:
|
||||
# https://github.com/mokoconsulting-tech/MokoStandards
|
||||
|
||||
standards_source: "https://github.com/mokoconsulting-tech/MokoStandards"
|
||||
standards_version: "04.04.00"
|
||||
platform: "waas-component"
|
||||
governed_repo: "mokoconsulting-tech/MokoJoomTOS"
|
||||
@@ -1,304 +0,0 @@
|
||||
<!--
|
||||
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: MokoStandards.Templates.GitHub
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
PATH: /templates/github/copilot-instructions.joomla.md.template
|
||||
VERSION: XX.YY.ZZ
|
||||
BRIEF: GitHub Copilot custom instructions template for Joomla/MokoWaaS governed repositories
|
||||
NOTE: Synced to .github/copilot-instructions.md in all Joomla/WaaS repos via bulk sync.
|
||||
Tokens replaced at sync time: MokoJoomTOS, https://github.com/mokoconsulting-tech/MokoJoomTOS, {{EXTENSION_NAME}},
|
||||
{{EXTENSION_TYPE}}, {{EXTENSION_ELEMENT}}
|
||||
-->
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **🔧 AI Self-Update Required on First Use**
|
||||
>
|
||||
> This file may contain unfilled `{{TOKEN}}` placeholders. The MokoStandards bulk-sync system
|
||||
> replaces them automatically at sync time; if you can still see them, sync has not yet run for
|
||||
> this repository — or this is a brand-new repo.
|
||||
>
|
||||
> **On your very first interaction in this repository, before answering any other question:**
|
||||
>
|
||||
> 1. Check whether any `{{TOKEN}}` placeholders remain in this file.
|
||||
> 2. If they do, locate the values as described in the table below and replace every occurrence
|
||||
> in **both** `.github/copilot-instructions.md` and `.github/CLAUDE.md`.
|
||||
> 3. After replacing all tokens, **delete this entire notice block** (from `> [!IMPORTANT]`
|
||||
> through the closing `> ---` line) in both files.
|
||||
> 4. Tell the developer: *"I've self-updated the AI context files with this repository's
|
||||
> values — all set."*
|
||||
>
|
||||
> | Placeholder | Where to find the value |
|
||||
> |---|---|
|
||||
> | `MokoJoomTOS` | The GitHub repository name (visible in the URL, `README.md` heading, or `git remote -v`) |
|
||||
> | `https://github.com/mokoconsulting-tech/MokoJoomTOS` | Full GitHub URL, e.g. `https://github.com/mokoconsulting-tech/<repo-name>` |
|
||||
> | `{{EXTENSION_NAME}}` | The `<name>` element in `manifest.xml` at the repository root |
|
||||
> | `{{EXTENSION_TYPE}}` | The `type` attribute of the `<extension>` tag in `manifest.xml` (`component`, `module`, `plugin`, or `template`) |
|
||||
> | `{{EXTENSION_ELEMENT}}` | The `<element>` tag in `manifest.xml`, or the filename prefix (e.g. `com_myextension`, `mod_mymodule`) |
|
||||
>
|
||||
> ---
|
||||
|
||||
# MokoJoomTOS — GitHub Copilot Custom Instructions
|
||||
|
||||
## What This Repo Is
|
||||
|
||||
This is a **Moko Consulting MokoWaaS** (Joomla) repository governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). All coding standards, workflows, and policies are defined there and enforced here via bulk sync.
|
||||
|
||||
Repository URL: https://github.com/mokoconsulting-tech/MokoJoomTOS
|
||||
Extension name: **{{EXTENSION_NAME}}**
|
||||
Extension type: **{{EXTENSION_TYPE}}** (`{{EXTENSION_ELEMENT}}`)
|
||||
Platform: **Joomla 4.x / MokoWaaS**
|
||||
|
||||
---
|
||||
|
||||
## Primary Language
|
||||
|
||||
**PHP** (≥ 7.4) is the primary language for this Joomla extension. JavaScript may be used for frontend enhancements. YAML uses 2-space indentation. All other text files use tabs per `.editorconfig`.
|
||||
|
||||
---
|
||||
|
||||
## File Header — Always Required on New Files
|
||||
|
||||
Every new file needs a copyright header as its first content.
|
||||
|
||||
**PHP:**
|
||||
```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: MokoJoomTOS.{{EXTENSION_TYPE}}
|
||||
* INGROUP: MokoJoomTOS
|
||||
* REPO: https://github.com/mokoconsulting-tech/MokoJoomTOS
|
||||
* PATH: /path/to/file.php
|
||||
* VERSION: XX.YY.ZZ
|
||||
* BRIEF: One-line description of purpose
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
```
|
||||
|
||||
**Markdown:**
|
||||
```markdown
|
||||
<!--
|
||||
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: MokoJoomTOS.Documentation
|
||||
INGROUP: MokoJoomTOS
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoJoomTOS
|
||||
PATH: /docs/file.md
|
||||
VERSION: XX.YY.ZZ
|
||||
BRIEF: One-line description
|
||||
-->
|
||||
```
|
||||
|
||||
**YAML / Shell / XML:** Use the appropriate comment syntax with the same fields. JSON files are exempt.
|
||||
|
||||
---
|
||||
|
||||
## Version Management
|
||||
|
||||
**`README.md` is the single source of truth for the repository version.**
|
||||
|
||||
- **Bump the patch version on every PR** — increment `XX.YY.ZZ` (e.g. `01.02.03` → `01.02.04`) in `README.md` before opening the PR; the `sync-version-on-merge` workflow propagates it automatically to all badges and `FILE INFORMATION` headers on merge to `main`.
|
||||
- The `VERSION: XX.YY.ZZ` field in `README.md` governs all other version references.
|
||||
- Version format is zero-padded semver: `XX.YY.ZZ` (e.g. `01.02.03`).
|
||||
- Never hardcode a specific version in document body text — use the badge or FILE INFORMATION header only.
|
||||
|
||||
### Joomla Version Alignment
|
||||
|
||||
The version in `README.md` **must always match** the `<version>` tag in `manifest.xml` and the latest entry in `updates.xml`. The `make release` command / release workflow updates all three automatically.
|
||||
|
||||
```xml
|
||||
<!-- In manifest.xml — must match README.md version -->
|
||||
<version>01.02.04</version>
|
||||
|
||||
<!-- In updates.xml — prepend a new <update> block for every release.
|
||||
Note: the backslash in version="4\.[0-9]+" is a literal backslash character
|
||||
in the XML attribute value. Joomla's update server treats the value as a
|
||||
regular expression, so \. matches a literal dot. -->
|
||||
<updates>
|
||||
<update>
|
||||
<name>{{EXTENSION_NAME}}</name>
|
||||
<version>01.02.04</version>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">
|
||||
https://github.com/mokoconsulting-tech/MokoJoomTOS/releases/download/01.02.04/{{EXTENSION_ELEMENT}}-01.02.04.zip
|
||||
</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="4\.[0-9]+" />
|
||||
</update>
|
||||
<!-- … older entries preserved below … -->
|
||||
</updates>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Joomla Extension Structure
|
||||
|
||||
```
|
||||
MokoJoomTOS/
|
||||
├── manifest.xml # Joomla installer manifest (root — required)
|
||||
├── updates.xml # Update server manifest (root — required, see below)
|
||||
├── site/ # Frontend (site) code
|
||||
│ ├── controller.php
|
||||
│ ├── controllers/
|
||||
│ ├── models/
|
||||
│ └── views/
|
||||
├── admin/ # Backend (admin) code
|
||||
│ ├── controller.php
|
||||
│ ├── controllers/
|
||||
│ ├── models/
|
||||
│ ├── views/
|
||||
│ └── sql/
|
||||
├── language/ # Language INI files
|
||||
├── media/ # CSS, JS, images (deployed to /media/{{EXTENSION_ELEMENT}}/)
|
||||
├── docs/ # Technical documentation
|
||||
├── tests/ # Test suite
|
||||
├── .github/
|
||||
│ ├── workflows/
|
||||
│ ├── copilot-instructions.md # This file
|
||||
│ └── CLAUDE.md
|
||||
├── README.md # Version source of truth
|
||||
├── CHANGELOG.md
|
||||
├── CONTRIBUTING.md
|
||||
├── LICENSE # GPL-3.0-or-later
|
||||
└── Makefile # Build automation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## updates.xml — Required in Repo Root
|
||||
|
||||
`updates.xml` **must exist at the repository root**. It is the Joomla update server manifest that allows Joomla installations to check for new versions of this extension.
|
||||
|
||||
The `manifest.xml` must reference it via:
|
||||
```xml
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="{{EXTENSION_NAME}}">
|
||||
https://github.com/mokoconsulting-tech/MokoJoomTOS/raw/main/updates.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Every release must prepend a new `<update>` block at the top of `updates.xml` — old entries must be preserved below.
|
||||
- The `<version>` in `updates.xml` must exactly match `<version>` in `manifest.xml` and the version in `README.md`.
|
||||
- The `<downloadurl>` must be a publicly accessible direct download link (GitHub Releases asset URL).
|
||||
- `<targetplatform name="joomla" version="4\.[0-9]+">` — the backslash is a **literal backslash character** in the XML attribute value; Joomla's update-server parser treats the value as a regular expression, so `\.` matches a literal dot and `[0-9]+` matches one or more digits. Do not double-escape it.
|
||||
|
||||
---
|
||||
|
||||
## manifest.xml Rules
|
||||
|
||||
- Lives at the repo root as `manifest.xml` (not inside `site/` or `admin/`).
|
||||
- `<version>` tag must be kept in sync with `README.md` version and `updates.xml`.
|
||||
- Must include `<updateservers>` block pointing to this repo's `updates.xml`.
|
||||
- Must include `<files folder="site">` and `<administration>` sections.
|
||||
- Joomla 4.x requires `<namespace path="src">Moko\{{EXTENSION_NAME}}</namespace>` for namespaced extensions.
|
||||
|
||||
---
|
||||
|
||||
## GitHub Actions — Token Usage
|
||||
|
||||
Every workflow must use **`secrets.GH_TOKEN`** (the org-level Personal Access Token).
|
||||
|
||||
```yaml
|
||||
# ✅ Correct
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ❌ Wrong — never use these in workflows
|
||||
token: ${{ github.token }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MokoStandards Reference
|
||||
|
||||
This repository is governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). Authoritative policies:
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [file-header-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/file-header-standards.md) | Copyright-header rules for every file type |
|
||||
| [coding-style-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/coding-style-guide.md) | Naming and formatting conventions |
|
||||
| [branching-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branching-strategy.md) | Branch naming, hierarchy, and release workflow |
|
||||
| [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) | Squash-merge policy and PR title/body conventions |
|
||||
| [changelog-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/changelog-standards.md) | How and when to update CHANGELOG.md |
|
||||
| [joomla-development-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/guide/waas/joomla-development-guide.md) | MokoWaaS Joomla extension development guide |
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Context | Convention | Example |
|
||||
|---------|-----------|---------|
|
||||
| PHP class | `PascalCase` | `MyController` |
|
||||
| PHP method / function | `camelCase` | `getItems()` |
|
||||
| PHP variable | `$snake_case` | `$item_id` |
|
||||
| PHP constant | `UPPER_SNAKE_CASE` | `MAX_ITEMS` |
|
||||
| PHP class file | `PascalCase.php` | `ItemModel.php` |
|
||||
| YAML workflow | `kebab-case.yml` | `ci-joomla.yml` |
|
||||
| Markdown doc | `kebab-case.md` | `installation-guide.md` |
|
||||
|
||||
---
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Format: `<type>(<scope>): <subject>` — imperative, lower-case subject, no trailing period.
|
||||
|
||||
Valid types: `feat` · `fix` · `docs` · `chore` · `ci` · `refactor` · `style` · `test` · `perf` · `revert` · `build`
|
||||
|
||||
---
|
||||
|
||||
## Branch Naming
|
||||
|
||||
Format: `<prefix>/<MAJOR.MINOR.PATCH>[/description]`
|
||||
|
||||
Approved prefixes: `dev/` · `rc/` · `version/` · `patch/` · `copilot/` · `dependabot/`
|
||||
|
||||
---
|
||||
|
||||
## Keeping Documentation Current
|
||||
|
||||
| Change type | Documentation to update |
|
||||
|-------------|------------------------|
|
||||
| New or renamed PHP class/method | PHPDoc block; `docs/api/` entry |
|
||||
| New or changed manifest.xml | Update `updates.xml` version; bump README.md version |
|
||||
| New release | Prepend `<update>` block to `updates.xml`; update CHANGELOG.md; bump README.md version |
|
||||
| New or changed workflow | `docs/workflows/<workflow-name>.md` |
|
||||
| Any modified file | Update the `VERSION` field in that file's `FILE INFORMATION` block |
|
||||
| **Every PR** | **Bump the patch version** — increment `XX.YY.ZZ` in `README.md`; `sync-version-on-merge` propagates it |
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints
|
||||
|
||||
- Never commit directly to `main` — all changes go via PR, squash-merged
|
||||
- Never skip the FILE INFORMATION block on a new file
|
||||
- Never add `defined('_JEXEC') or die;` to CLI scripts or model tests — only to web-accessible PHP files
|
||||
- Never hardcode version numbers in body text — update `README.md` and let automation propagate
|
||||
- Never use `github.token` or `secrets.GITHUB_TOKEN` in workflows — always use `secrets.GH_TOKEN`
|
||||
- Never let `manifest.xml` version, `updates.xml` version, and `README.md` version go out of sync
|
||||
@@ -1,54 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# CODEOWNERS — require approval from jmiller-moko for protected paths
|
||||
# Synced from MokoStandards. Do not edit manually.
|
||||
#
|
||||
# Changes to these paths require review from the listed owners before merge.
|
||||
# Combined with branch protection (require PR reviews), this prevents
|
||||
# unauthorized modifications to workflows, configs, and governance files.
|
||||
|
||||
# ── Synced workflows (managed by MokoStandards — do not edit manually) ────
|
||||
/.github/workflows/deploy-dev.yml @jmiller-moko
|
||||
/.github/workflows/deploy-demo.yml @jmiller-moko
|
||||
/.github/workflows/deploy-manual.yml @jmiller-moko
|
||||
/.github/workflows/auto-release.yml @jmiller-moko
|
||||
/.github/workflows/auto-dev-issue.yml @jmiller-moko
|
||||
/.github/workflows/auto-assign.yml @jmiller-moko
|
||||
/.github/workflows/sync-version-on-merge.yml @jmiller-moko
|
||||
/.github/workflows/enterprise-firewall-setup.yml @jmiller-moko
|
||||
/.github/workflows/repository-cleanup.yml @jmiller-moko
|
||||
/.github/workflows/standards-compliance.yml @jmiller-moko
|
||||
/.github/workflows/codeql-analysis.yml @jmiller-moko
|
||||
/.github/workflows/repo_health.yml @jmiller-moko
|
||||
/.github/workflows/ci-joomla.yml @jmiller-moko
|
||||
/.github/workflows/update-server.yml @jmiller-moko
|
||||
/.github/workflows/deploy-manual.yml @jmiller-moko
|
||||
/.github/workflows/ci-dolibarr.yml @jmiller-moko
|
||||
/.github/workflows/publish-to-mokodolimods.yml @jmiller-moko
|
||||
/.github/workflows/changelog-validation.yml @jmiller-moko
|
||||
# Custom workflows in .github/workflows/ not listed above are repo-owned.
|
||||
|
||||
# ── GitHub configuration ─────────────────────────────────────────────────
|
||||
/.github/ISSUE_TEMPLATE/ @jmiller-moko
|
||||
/.github/CODEOWNERS @jmiller-moko
|
||||
/.github/copilot.yml @jmiller-moko
|
||||
/.github/copilot-instructions.md @jmiller-moko
|
||||
/.github/CLAUDE.md @jmiller-moko
|
||||
/.github/.mokostandards @jmiller-moko
|
||||
|
||||
# ── Build and config files ───────────────────────────────────────────────
|
||||
/composer.json @jmiller-moko
|
||||
/phpstan.neon @jmiller-moko
|
||||
/Makefile @jmiller-moko
|
||||
/.ftpignore @jmiller-moko
|
||||
/.gitignore @jmiller-moko
|
||||
/.gitattributes @jmiller-moko
|
||||
/.editorconfig @jmiller-moko
|
||||
|
||||
# ── Governance documents ─────────────────────────────────────────────────
|
||||
/LICENSE @jmiller-moko
|
||||
/CONTRIBUTING.md @jmiller-moko
|
||||
/SECURITY.md @jmiller-moko
|
||||
/GOVERNANCE.md @jmiller-moko
|
||||
/CODE_OF_CONDUCT.md @jmiller-moko
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
name: Enterprise Support Request
|
||||
about: Request enterprise-level support or consultation
|
||||
title: '[ENTERPRISE] '
|
||||
labels: 'enterprise, support'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Support Request Type
|
||||
- [ ] Critical Production Issue
|
||||
- [ ] Performance Optimization
|
||||
- [ ] Security Audit
|
||||
- [ ] Architecture Review
|
||||
- [ ] Custom Development
|
||||
- [ ] Migration Support
|
||||
- [ ] Training & Onboarding
|
||||
- [ ] Other (please specify)
|
||||
|
||||
## Priority Level
|
||||
- [ ] P0 - Critical (Production Down)
|
||||
- [ ] P1 - High (Major Feature Broken)
|
||||
- [ ] P2 - Medium (Non-Critical Issue)
|
||||
- [ ] P3 - Low (Enhancement/Question)
|
||||
|
||||
## Organization Details
|
||||
- **Company Name**:
|
||||
- **Contact Person**:
|
||||
- **Email**:
|
||||
- **Phone** (for P0/P1 issues):
|
||||
- **Timezone**:
|
||||
|
||||
## Issue Description
|
||||
Provide a clear and detailed description of your request or issue.
|
||||
|
||||
## Business Impact
|
||||
Describe the impact on your business operations:
|
||||
- Number of users affected:
|
||||
- Revenue impact (if applicable):
|
||||
- Deadline/SLA requirements:
|
||||
|
||||
## Environment Details
|
||||
- **Deployment Type**: [On-Premise / Cloud / Hybrid]
|
||||
- **Platform**: [Joomla / Dolibarr / Custom]
|
||||
- **Version**:
|
||||
- **Infrastructure**: [AWS / Azure / GCP / Other]
|
||||
- **Scale**: [Users / Transactions / Data Volume]
|
||||
|
||||
## Current Configuration
|
||||
```yaml
|
||||
# Paste relevant configuration (sanitize sensitive data)
|
||||
```
|
||||
|
||||
## Logs and Diagnostics
|
||||
```
|
||||
# Paste relevant logs (sanitize sensitive data)
|
||||
```
|
||||
|
||||
## Attempted Solutions
|
||||
Describe any troubleshooting steps already taken.
|
||||
|
||||
## Expected Resolution
|
||||
Describe your expected outcome or resolution.
|
||||
|
||||
## Additional Resources
|
||||
- **Documentation Links**:
|
||||
- **Related Issues**:
|
||||
- **Screenshots/Videos**:
|
||||
|
||||
## Enterprise SLA
|
||||
- [ ] Standard Support (initial response within 1–3 weeks)
|
||||
- [ ] Premium Support (initial response within 5 business days)
|
||||
- [ ] Critical Support (initial response within 72 hours)
|
||||
- [ ] Custom SLA (specify):
|
||||
|
||||
## Compliance Requirements
|
||||
- [ ] GDPR
|
||||
- [ ] HIPAA
|
||||
- [ ] SOC 2
|
||||
- [ ] ISO 27001
|
||||
- [ ] Other (specify):
|
||||
|
||||
---
|
||||
**Note**: Enterprise support requests require an active support contract. If you don't have one, please contact us at enterprise@mokoconsulting.tech
|
||||
@@ -1,190 +0,0 @@
|
||||
---
|
||||
name: Firewall Request
|
||||
about: Request firewall rule changes or access to external resources
|
||||
title: '[FIREWALL] [Resource Name] - [Brief Description]'
|
||||
labels: ['firewall-request', 'infrastructure', 'security']
|
||||
assignees: ['jmiller-moko']
|
||||
---
|
||||
|
||||
|
||||
## Firewall Request
|
||||
|
||||
### Request Type
|
||||
- [ ] Allow outbound access to external service/API
|
||||
- [ ] Allow inbound access from external source
|
||||
- [ ] Modify existing firewall rule
|
||||
- [ ] Remove/revoke firewall rule
|
||||
- [ ] Other (specify):
|
||||
|
||||
### Resource Information
|
||||
**Service/Domain Name**:
|
||||
**IP Address(es)**:
|
||||
**Port(s)**:
|
||||
**Protocol**:
|
||||
- [ ] HTTP (80)
|
||||
- [ ] HTTPS (443)
|
||||
- [ ] SSH (22)
|
||||
- [ ] FTP (21)
|
||||
- [ ] SFTP (22)
|
||||
- [ ] Custom (specify): _______________
|
||||
|
||||
### Requestor Information
|
||||
**Name**:
|
||||
**GitHub Username**: @
|
||||
**Email**: @mokoconsulting.tech
|
||||
**Team/Department**:
|
||||
**Manager**: @
|
||||
|
||||
### Business Justification
|
||||
**Why is this access needed?**
|
||||
|
||||
**Which project(s) require this access?**
|
||||
|
||||
**What functionality will break without this access?**
|
||||
|
||||
**Is there an alternative solution?**
|
||||
- [ ] Yes (explain):
|
||||
- [ ] No
|
||||
|
||||
### Security Considerations
|
||||
**Data Classification**:
|
||||
- [ ] Public
|
||||
- [ ] Internal
|
||||
- [ ] Confidential
|
||||
- [ ] Restricted
|
||||
|
||||
**Sensitive Data Transmission**:
|
||||
- [ ] No sensitive data will be transmitted
|
||||
- [ ] Sensitive data will be transmitted (encryption required)
|
||||
- [ ] Authentication credentials will be transmitted (secure storage required)
|
||||
|
||||
**Third-Party Service**:
|
||||
- [ ] This is a trusted/verified third-party service
|
||||
- [ ] This is a new/unverified service (security review required)
|
||||
|
||||
**Service Documentation**:
|
||||
(Provide link to service documentation or API specs)
|
||||
|
||||
### Access Scope
|
||||
**Affected Systems**:
|
||||
- [ ] Development environment only
|
||||
- [ ] Staging environment only
|
||||
- [ ] Production environment
|
||||
- [ ] All environments
|
||||
|
||||
**Access Duration**:
|
||||
- [ ] Permanent (ongoing business need)
|
||||
- [ ] Temporary (specify end date): _______________
|
||||
- [ ] Testing only (specify duration): _______________
|
||||
|
||||
### Technical Details
|
||||
**Source System(s)**:
|
||||
(Which internal systems need access?)
|
||||
|
||||
**Destination System(s)**:
|
||||
(Which external systems need to be accessed?)
|
||||
|
||||
**Expected Traffic Volume**:
|
||||
(e.g., requests per hour/day)
|
||||
|
||||
**Traffic Pattern**:
|
||||
- [ ] Continuous
|
||||
- [ ] Periodic (specify frequency): _______________
|
||||
- [ ] On-demand/manual
|
||||
- [ ] Scheduled (specify schedule): _______________
|
||||
|
||||
### Testing Requirements
|
||||
**Pre-Production Testing**:
|
||||
- [ ] Request includes dev/staging access for testing
|
||||
- [ ] Testing can be done with production access only
|
||||
- [ ] No testing required (modify existing rule)
|
||||
|
||||
**Testing Plan**:
|
||||
|
||||
**Rollback Plan**:
|
||||
(What happens if access needs to be revoked?)
|
||||
|
||||
### Compliance & Audit
|
||||
**Compliance Requirements**:
|
||||
- [ ] GDPR considerations
|
||||
- [ ] SOC 2 compliance required
|
||||
- [ ] PCI DSS considerations
|
||||
- [ ] Other regulatory requirements: _______________
|
||||
- [ ] No specific compliance requirements
|
||||
|
||||
**Audit/Logging Requirements**:
|
||||
- [ ] Standard logging sufficient
|
||||
- [ ] Enhanced logging/monitoring required
|
||||
- [ ] Real-time alerting required
|
||||
|
||||
### Urgency
|
||||
- [ ] Critical (production down, immediate access needed)
|
||||
- [ ] High (needed within 24 hours)
|
||||
- [ ] Normal (needed within 1 week)
|
||||
- [ ] Low priority (needed within 1 month)
|
||||
|
||||
**If critical/high urgency, explain why:**
|
||||
|
||||
### Approvals
|
||||
**Manager Approval**:
|
||||
- [ ] Manager has been notified and approves this request
|
||||
|
||||
**Security Team Review Required**:
|
||||
- [ ] Yes (new external service, sensitive data)
|
||||
- [ ] No (minor change, established service)
|
||||
|
||||
### Additional Information
|
||||
|
||||
**Related Documentation**:
|
||||
(Links to relevant docs, RFCs, tickets, etc.)
|
||||
|
||||
**Dependencies**:
|
||||
(Other systems or changes this depends on)
|
||||
|
||||
**Comments/Questions**:
|
||||
|
||||
---
|
||||
|
||||
## For Infrastructure/Security Team Use Only
|
||||
|
||||
**Do not edit below this line**
|
||||
|
||||
### Security Review
|
||||
- [ ] Security team review completed
|
||||
- [ ] Risk assessment: Low / Medium / High
|
||||
- [ ] Encryption required: Yes / No
|
||||
- [ ] VPN required: Yes / No
|
||||
- [ ] Additional security controls: _______________
|
||||
|
||||
**Reviewed By**: @_______________
|
||||
**Review Date**: _______________
|
||||
**Review Notes**:
|
||||
|
||||
### Implementation
|
||||
- [ ] Firewall rule created/modified
|
||||
- [ ] Rule tested in dev/staging
|
||||
- [ ] Rule deployed to production
|
||||
- [ ] Monitoring/alerting configured
|
||||
- [ ] Documentation updated
|
||||
|
||||
**Firewall Rule ID**: _______________
|
||||
**Implementation Date**: _______________
|
||||
**Implemented By**: @_______________
|
||||
|
||||
**Configuration Details**:
|
||||
```
|
||||
Source:
|
||||
Destination:
|
||||
Port/Protocol:
|
||||
Action: Allow/Deny
|
||||
```
|
||||
|
||||
### Verification
|
||||
- [ ] Requestor confirmed access working
|
||||
- [ ] Logs reviewed (no anomalies)
|
||||
- [ ] Security scan completed (if applicable)
|
||||
|
||||
**Verification Date**: _______________
|
||||
**Verified By**: @_______________
|
||||
|
||||
### Notes
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: License Request
|
||||
about: Request an organization license for Sublime Text
|
||||
title: '[LICENSE REQUEST] Sublime Text - [Your Name]'
|
||||
labels: ['license-request', 'admin']
|
||||
assignees: ['jmiller-moko']
|
||||
---
|
||||
|
||||
|
||||
## License Request
|
||||
|
||||
### Tool Information
|
||||
**Tool Name**: Sublime Text
|
||||
|
||||
**License Type Requested**: Organization Pool
|
||||
|
||||
**Personal Purchase**:
|
||||
- [ ] I prefer to purchase my own license ($99 USD - recommended, immediate access)
|
||||
- [ ] I prefer an organization license (1-2 business days, organization use only)
|
||||
- [ ] I have already purchased my own license (registration only for support)
|
||||
|
||||
### Requestor Information
|
||||
**Name**:
|
||||
**GitHub Username**: @
|
||||
**Email**: @mokoconsulting.tech
|
||||
**Team/Department**:
|
||||
**Manager**: @
|
||||
|
||||
### Justification
|
||||
**Why do you need this license?**
|
||||
|
||||
**Primary use case**:
|
||||
- [ ] Remote development (SFTP to servers)
|
||||
- [ ] Local development
|
||||
- [ ] Code review
|
||||
- [ ] Documentation editing
|
||||
- [ ] Other (specify):
|
||||
|
||||
**Which projects/repositories will you work on?**
|
||||
|
||||
**Have you evaluated the free trial?**
|
||||
- [ ] Yes, I've used the trial and Sublime Text meets my needs
|
||||
- [ ] No, requesting license before trial
|
||||
|
||||
**Alternative tools considered**:
|
||||
- [ ] VS Code (free alternative)
|
||||
- [ ] Vim/Neovim (free, terminal-based)
|
||||
- [ ] Other: _______________
|
||||
|
||||
### Platform
|
||||
- [ ] Windows
|
||||
- [ ] macOS
|
||||
- [ ] Linux (distribution: ________)
|
||||
|
||||
### Urgency
|
||||
- [ ] Urgent (needed within 24 hours - please justify)
|
||||
- [ ] Normal (1-2 business days)
|
||||
- [ ] Low priority (when available)
|
||||
|
||||
**If urgent, please explain why:**
|
||||
|
||||
### SFTP Plugin
|
||||
**Note**: Sublime SFTP plugin ($16 USD) is a **separate personal purchase** and is NOT provided by the organization.
|
||||
|
||||
- [ ] I understand SFTP plugin requires separate personal purchase
|
||||
- [ ] I have already purchased SFTP plugin
|
||||
- [ ] I will purchase SFTP plugin if needed for my work
|
||||
- [ ] I don't need SFTP plugin (local development only)
|
||||
|
||||
### Acknowledgments
|
||||
- [ ] I have read the License Management Policy (/docs/github-private/LICENSE_MANAGEMENT.md)
|
||||
- [ ] I understand organization licenses are for work use only
|
||||
- [ ] I understand organization licenses must be returned upon leaving
|
||||
- [ ] I understand personal purchases ($99) are an alternative with lifetime access
|
||||
- [ ] I understand SFTP plugin ($16) requires separate personal purchase
|
||||
- [ ] I agree to the terms of use
|
||||
|
||||
### Additional Information
|
||||
|
||||
**Expected daily usage hours**: _____ hours/day
|
||||
|
||||
**Duration of need**:
|
||||
- [ ] Permanent (ongoing role)
|
||||
- [ ] Temporary project (_____ months)
|
||||
- [ ] Trial/Evaluation (_____ weeks)
|
||||
|
||||
**Comments/Questions**:
|
||||
|
||||
---
|
||||
|
||||
## For Admin Use Only
|
||||
|
||||
**Do not edit below this line**
|
||||
|
||||
- [ ] Manager approval received (@manager-username)
|
||||
- [ ] License available in pool (current: __/20)
|
||||
- [ ] License type confirmed (Organization / Personal registration)
|
||||
- [ ] License key sent via encrypted email
|
||||
- [ ] Activation confirmed by user
|
||||
- [ ] Added to license tracking sheet
|
||||
- [ ] User notified of SFTP plugin requirement
|
||||
|
||||
**License Key ID**: _____________
|
||||
**Date Issued**: _____________
|
||||
**Issued By**: @_____________
|
||||
|
||||
**Notes**:
|
||||
@@ -1,304 +0,0 @@
|
||||
<!--
|
||||
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: MokoStandards.Templates.GitHub
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
PATH: /templates/github/copilot-instructions.joomla.md.template
|
||||
VERSION: XX.YY.ZZ
|
||||
BRIEF: GitHub Copilot custom instructions template for Joomla/MokoWaaS governed repositories
|
||||
NOTE: Synced to .github/copilot-instructions.md in all Joomla/WaaS repos via bulk sync.
|
||||
Tokens replaced at sync time: MokoJoomTOS, https://github.com/mokoconsulting-tech/MokoJoomTOS, {{EXTENSION_NAME}},
|
||||
{{EXTENSION_TYPE}}, {{EXTENSION_ELEMENT}}
|
||||
-->
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **🔧 AI Self-Update Required on First Use**
|
||||
>
|
||||
> This file may contain unfilled `{{TOKEN}}` placeholders. The MokoStandards bulk-sync system
|
||||
> replaces them automatically at sync time; if you can still see them, sync has not yet run for
|
||||
> this repository — or this is a brand-new repo.
|
||||
>
|
||||
> **On your very first interaction in this repository, before answering any other question:**
|
||||
>
|
||||
> 1. Check whether any `{{TOKEN}}` placeholders remain in this file.
|
||||
> 2. If they do, locate the values as described in the table below and replace every occurrence
|
||||
> in **both** `.github/copilot-instructions.md` and `.github/CLAUDE.md`.
|
||||
> 3. After replacing all tokens, **delete this entire notice block** (from `> [!IMPORTANT]`
|
||||
> through the closing `> ---` line) in both files.
|
||||
> 4. Tell the developer: *"I've self-updated the AI context files with this repository's
|
||||
> values — all set."*
|
||||
>
|
||||
> | Placeholder | Where to find the value |
|
||||
> |---|---|
|
||||
> | `MokoJoomTOS` | The GitHub repository name (visible in the URL, `README.md` heading, or `git remote -v`) |
|
||||
> | `https://github.com/mokoconsulting-tech/MokoJoomTOS` | Full GitHub URL, e.g. `https://github.com/mokoconsulting-tech/<repo-name>` |
|
||||
> | `{{EXTENSION_NAME}}` | The `<name>` element in `manifest.xml` at the repository root |
|
||||
> | `{{EXTENSION_TYPE}}` | The `type` attribute of the `<extension>` tag in `manifest.xml` (`component`, `module`, `plugin`, or `template`) |
|
||||
> | `{{EXTENSION_ELEMENT}}` | The `<element>` tag in `manifest.xml`, or the filename prefix (e.g. `com_myextension`, `mod_mymodule`) |
|
||||
>
|
||||
> ---
|
||||
|
||||
# MokoJoomTOS — GitHub Copilot Custom Instructions
|
||||
|
||||
## What This Repo Is
|
||||
|
||||
This is a **Moko Consulting MokoWaaS** (Joomla) repository governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). All coding standards, workflows, and policies are defined there and enforced here via bulk sync.
|
||||
|
||||
Repository URL: https://github.com/mokoconsulting-tech/MokoJoomTOS
|
||||
Extension name: **{{EXTENSION_NAME}}**
|
||||
Extension type: **{{EXTENSION_TYPE}}** (`{{EXTENSION_ELEMENT}}`)
|
||||
Platform: **Joomla 4.x / MokoWaaS**
|
||||
|
||||
---
|
||||
|
||||
## Primary Language
|
||||
|
||||
**PHP** (≥ 7.4) is the primary language for this Joomla extension. JavaScript may be used for frontend enhancements. YAML uses 2-space indentation. All other text files use tabs per `.editorconfig`.
|
||||
|
||||
---
|
||||
|
||||
## File Header — Always Required on New Files
|
||||
|
||||
Every new file needs a copyright header as its first content.
|
||||
|
||||
**PHP:**
|
||||
```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: MokoJoomTOS.{{EXTENSION_TYPE}}
|
||||
* INGROUP: MokoJoomTOS
|
||||
* REPO: https://github.com/mokoconsulting-tech/MokoJoomTOS
|
||||
* PATH: /path/to/file.php
|
||||
* VERSION: XX.YY.ZZ
|
||||
* BRIEF: One-line description of purpose
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
```
|
||||
|
||||
**Markdown:**
|
||||
```markdown
|
||||
<!--
|
||||
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: MokoJoomTOS.Documentation
|
||||
INGROUP: MokoJoomTOS
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoJoomTOS
|
||||
PATH: /docs/file.md
|
||||
VERSION: XX.YY.ZZ
|
||||
BRIEF: One-line description
|
||||
-->
|
||||
```
|
||||
|
||||
**YAML / Shell / XML:** Use the appropriate comment syntax with the same fields. JSON files are exempt.
|
||||
|
||||
---
|
||||
|
||||
## Version Management
|
||||
|
||||
**`README.md` is the single source of truth for the repository version.**
|
||||
|
||||
- **Bump the patch version on every PR** — increment `XX.YY.ZZ` (e.g. `01.02.03` → `01.02.04`) in `README.md` before opening the PR; the `sync-version-on-merge` workflow propagates it automatically to all badges and `FILE INFORMATION` headers on merge to `main`.
|
||||
- The `VERSION: XX.YY.ZZ` field in `README.md` governs all other version references.
|
||||
- Version format is zero-padded semver: `XX.YY.ZZ` (e.g. `01.02.03`).
|
||||
- Never hardcode a specific version in document body text — use the badge or FILE INFORMATION header only.
|
||||
|
||||
### Joomla Version Alignment
|
||||
|
||||
The version in `README.md` **must always match** the `<version>` tag in `manifest.xml` and the latest entry in `updates.xml`. The `make release` command / release workflow updates all three automatically.
|
||||
|
||||
```xml
|
||||
<!-- In manifest.xml — must match README.md version -->
|
||||
<version>01.02.04</version>
|
||||
|
||||
<!-- In updates.xml — prepend a new <update> block for every release.
|
||||
Note: the backslash in version="4\.[0-9]+" is a literal backslash character
|
||||
in the XML attribute value. Joomla's update server treats the value as a
|
||||
regular expression, so \. matches a literal dot. -->
|
||||
<updates>
|
||||
<update>
|
||||
<name>{{EXTENSION_NAME}}</name>
|
||||
<version>01.02.04</version>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">
|
||||
https://github.com/mokoconsulting-tech/MokoJoomTOS/releases/download/01.02.04/{{EXTENSION_ELEMENT}}-01.02.04.zip
|
||||
</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="4\.[0-9]+" />
|
||||
</update>
|
||||
<!-- … older entries preserved below … -->
|
||||
</updates>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Joomla Extension Structure
|
||||
|
||||
```
|
||||
MokoJoomTOS/
|
||||
├── manifest.xml # Joomla installer manifest (root — required)
|
||||
├── updates.xml # Update server manifest (root — required, see below)
|
||||
├── site/ # Frontend (site) code
|
||||
│ ├── controller.php
|
||||
│ ├── controllers/
|
||||
│ ├── models/
|
||||
│ └── views/
|
||||
├── admin/ # Backend (admin) code
|
||||
│ ├── controller.php
|
||||
│ ├── controllers/
|
||||
│ ├── models/
|
||||
│ ├── views/
|
||||
│ └── sql/
|
||||
├── language/ # Language INI files
|
||||
├── media/ # CSS, JS, images (deployed to /media/{{EXTENSION_ELEMENT}}/)
|
||||
├── docs/ # Technical documentation
|
||||
├── tests/ # Test suite
|
||||
├── .github/
|
||||
│ ├── workflows/
|
||||
│ ├── copilot-instructions.md # This file
|
||||
│ └── CLAUDE.md
|
||||
├── README.md # Version source of truth
|
||||
├── CHANGELOG.md
|
||||
├── CONTRIBUTING.md
|
||||
├── LICENSE # GPL-3.0-or-later
|
||||
└── Makefile # Build automation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## updates.xml — Required in Repo Root
|
||||
|
||||
`updates.xml` **must exist at the repository root**. It is the Joomla update server manifest that allows Joomla installations to check for new versions of this extension.
|
||||
|
||||
The `manifest.xml` must reference it via:
|
||||
```xml
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="{{EXTENSION_NAME}}">
|
||||
https://github.com/mokoconsulting-tech/MokoJoomTOS/raw/main/updates.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Every release must prepend a new `<update>` block at the top of `updates.xml` — old entries must be preserved below.
|
||||
- The `<version>` in `updates.xml` must exactly match `<version>` in `manifest.xml` and the version in `README.md`.
|
||||
- The `<downloadurl>` must be a publicly accessible direct download link (GitHub Releases asset URL).
|
||||
- `<targetplatform name="joomla" version="4\.[0-9]+">` — the backslash is a **literal backslash character** in the XML attribute value; Joomla's update-server parser treats the value as a regular expression, so `\.` matches a literal dot and `[0-9]+` matches one or more digits. Do not double-escape it.
|
||||
|
||||
---
|
||||
|
||||
## manifest.xml Rules
|
||||
|
||||
- Lives at the repo root as `manifest.xml` (not inside `site/` or `admin/`).
|
||||
- `<version>` tag must be kept in sync with `README.md` version and `updates.xml`.
|
||||
- Must include `<updateservers>` block pointing to this repo's `updates.xml`.
|
||||
- Must include `<files folder="site">` and `<administration>` sections.
|
||||
- Joomla 4.x requires `<namespace path="src">Moko\{{EXTENSION_NAME}}</namespace>` for namespaced extensions.
|
||||
|
||||
---
|
||||
|
||||
## GitHub Actions — Token Usage
|
||||
|
||||
Every workflow must use **`secrets.GH_TOKEN`** (the org-level Personal Access Token).
|
||||
|
||||
```yaml
|
||||
# ✅ Correct
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ❌ Wrong — never use these in workflows
|
||||
token: ${{ github.token }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MokoStandards Reference
|
||||
|
||||
This repository is governed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). Authoritative policies:
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [file-header-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/file-header-standards.md) | Copyright-header rules for every file type |
|
||||
| [coding-style-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/coding-style-guide.md) | Naming and formatting conventions |
|
||||
| [branching-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branching-strategy.md) | Branch naming, hierarchy, and release workflow |
|
||||
| [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) | Squash-merge policy and PR title/body conventions |
|
||||
| [changelog-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/changelog-standards.md) | How and when to update CHANGELOG.md |
|
||||
| [joomla-development-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/guide/waas/joomla-development-guide.md) | MokoWaaS Joomla extension development guide |
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Context | Convention | Example |
|
||||
|---------|-----------|---------|
|
||||
| PHP class | `PascalCase` | `MyController` |
|
||||
| PHP method / function | `camelCase` | `getItems()` |
|
||||
| PHP variable | `$snake_case` | `$item_id` |
|
||||
| PHP constant | `UPPER_SNAKE_CASE` | `MAX_ITEMS` |
|
||||
| PHP class file | `PascalCase.php` | `ItemModel.php` |
|
||||
| YAML workflow | `kebab-case.yml` | `ci-joomla.yml` |
|
||||
| Markdown doc | `kebab-case.md` | `installation-guide.md` |
|
||||
|
||||
---
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Format: `<type>(<scope>): <subject>` — imperative, lower-case subject, no trailing period.
|
||||
|
||||
Valid types: `feat` · `fix` · `docs` · `chore` · `ci` · `refactor` · `style` · `test` · `perf` · `revert` · `build`
|
||||
|
||||
---
|
||||
|
||||
## Branch Naming
|
||||
|
||||
Format: `<prefix>/<MAJOR.MINOR.PATCH>[/description]`
|
||||
|
||||
Approved prefixes: `dev/` · `rc/` · `version/` · `patch/` · `copilot/` · `dependabot/`
|
||||
|
||||
---
|
||||
|
||||
## Keeping Documentation Current
|
||||
|
||||
| Change type | Documentation to update |
|
||||
|-------------|------------------------|
|
||||
| New or renamed PHP class/method | PHPDoc block; `docs/api/` entry |
|
||||
| New or changed manifest.xml | Update `updates.xml` version; bump README.md version |
|
||||
| New release | Prepend `<update>` block to `updates.xml`; update CHANGELOG.md; bump README.md version |
|
||||
| New or changed workflow | `docs/workflows/<workflow-name>.md` |
|
||||
| Any modified file | Update the `VERSION` field in that file's `FILE INFORMATION` block |
|
||||
| **Every PR** | **Bump the patch version** — increment `XX.YY.ZZ` in `README.md`; `sync-version-on-merge` propagates it |
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints
|
||||
|
||||
- Never commit directly to `main` — all changes go via PR, squash-merged
|
||||
- Never skip the FILE INFORMATION block on a new file
|
||||
- Never add `defined('_JEXEC') or die;` to CLI scripts or model tests — only to web-accessible PHP files
|
||||
- Never hardcode version numbers in body text — update `README.md` and let automation propagate
|
||||
- Never use `github.token` or `secrets.GITHUB_TOKEN` in workflows — always use `secrets.GH_TOKEN`
|
||||
- Never let `manifest.xml` version, `updates.xml` version, and `README.md` version go out of sync
|
||||
@@ -1,137 +0,0 @@
|
||||
# GitHub Copilot Configuration
|
||||
# This file configures GitHub Copilot settings for the repository
|
||||
|
||||
# Allowed domains for Copilot to access
|
||||
# These domains are trusted sources that Copilot can fetch information from
|
||||
allowed_domains:
|
||||
# Standard license providers
|
||||
- "www.gnu.org" # GNU licenses (GPL, LGPL, AGPL)
|
||||
- "opensource.org" # Open Source Initiative
|
||||
- "choosealicense.com" # GitHub's license chooser
|
||||
- "spdx.org" # Software Package Data Exchange
|
||||
- "creativecommons.org" # Creative Commons licenses
|
||||
- "apache.org" # Apache Software Foundation
|
||||
- "fsf.org" # Free Software Foundation
|
||||
|
||||
# Documentation and standards
|
||||
- "semver.org" # Semantic Versioning
|
||||
- "keepachangelog.com" # Changelog standards
|
||||
- "conventionalcommits.org" # Commit message standards
|
||||
|
||||
# GitHub and related
|
||||
- "github.com" # GitHub main site
|
||||
- "docs.github.com" # GitHub documentation
|
||||
- "raw.githubusercontent.com" # GitHub raw content
|
||||
|
||||
# Package managers and registries
|
||||
- "npmjs.com" # npm registry
|
||||
- "pypi.org" # Python Package Index
|
||||
- "packagist.org" # PHP Composer packages
|
||||
- "rubygems.org" # Ruby gems
|
||||
|
||||
# Standards and specifications
|
||||
- "json-schema.org" # JSON Schema
|
||||
- "w3.org" # W3C standards
|
||||
- "ietf.org" # IETF RFCs and standards
|
||||
|
||||
# PHP and Joomla specific
|
||||
- "joomla.org" # Joomla CMS
|
||||
- "docs.joomla.org" # Joomla documentation
|
||||
- "downloads.joomla.org" # Joomla core downloads
|
||||
- "php.net" # PHP documentation
|
||||
- "getcomposer.org" # Composer dependency manager
|
||||
- "packagist.org" # Composer package registry (also listed under packages)
|
||||
|
||||
# Dolibarr specific
|
||||
- "dolibarr.org" # Dolibarr ERP/CRM
|
||||
- "wiki.dolibarr.org" # Dolibarr wiki
|
||||
- "docs.dolibarr.org" # Dolibarr developer documentation
|
||||
|
||||
# Moko Consulting
|
||||
- "mokoconsulting.tech" # Moko Consulting main site
|
||||
- "*.mokoconsulting.tech" # All Moko Consulting subdomains (API, docs, CDN, etc.)
|
||||
|
||||
# Google services
|
||||
- "drive.google.com" # Google Drive (file sharing and assets)
|
||||
- "docs.google.com" # Google Docs
|
||||
- "sheets.google.com" # Google Sheets
|
||||
- "accounts.google.com" # Google authentication
|
||||
- "storage.googleapis.com" # Google Cloud Storage
|
||||
- "*.googleapis.com" # Google APIs (Maps, Fonts, etc.)
|
||||
- "*.googleusercontent.com" # Google user-uploaded content and CDN
|
||||
- "fonts.googleapis.com" # Google Fonts CSS
|
||||
- "fonts.gstatic.com" # Google Fonts static assets
|
||||
|
||||
# GitHub extended
|
||||
- "api.github.com" # GitHub REST API
|
||||
- "upload.github.com" # GitHub file uploads
|
||||
- "objects.githubusercontent.com" # GitHub release assets and LFS
|
||||
- "user-images.githubusercontent.com" # GitHub issue/PR image attachments
|
||||
- "codeload.github.com" # GitHub archive downloads
|
||||
- "ghcr.io" # GitHub Container Registry
|
||||
- "pkg.github.com" # GitHub Packages
|
||||
|
||||
# Developer reference
|
||||
- "developer.mozilla.org" # MDN Web Docs
|
||||
- "stackoverflow.com" # Stack Overflow
|
||||
- "git-scm.com" # Git documentation
|
||||
|
||||
# CDN and infrastructure
|
||||
- "cdn.jsdelivr.net" # jsDelivr CDN
|
||||
- "unpkg.com" # unpkg CDN
|
||||
- "cdnjs.cloudflare.com" # Cloudflare CDN
|
||||
- "img.shields.io" # Shields.io badge images
|
||||
- "shields.io" # Shields.io badge service
|
||||
|
||||
# Container registries
|
||||
- "hub.docker.com" # Docker Hub
|
||||
- "registry-1.docker.io" # Docker registry pulls
|
||||
- "index.docker.io" # Docker index
|
||||
|
||||
# CI / code quality
|
||||
- "codecov.io" # Code coverage reporting
|
||||
- "coveralls.io" # Coveralls coverage service
|
||||
- "sonarcloud.io" # SonarCloud static analysis
|
||||
|
||||
# Terraform / infrastructure
|
||||
- "registry.terraform.io" # Terraform provider registry
|
||||
- "releases.hashicorp.com" # HashiCorp release downloads
|
||||
- "checkpoint-api.hashicorp.com" # HashiCorp update checks
|
||||
|
||||
# Settings for code generation and suggestions
|
||||
copilot:
|
||||
# Enable Copilot for this repository
|
||||
enabled: true
|
||||
|
||||
# File patterns to include for Copilot suggestions
|
||||
include:
|
||||
- "**/*.py"
|
||||
- "**/*.js"
|
||||
- "**/*.php"
|
||||
- "**/*.md"
|
||||
- "**/*.yml"
|
||||
- "**/*.yaml"
|
||||
- "**/*.json"
|
||||
- "**/*.xml"
|
||||
- "**/*.sh"
|
||||
|
||||
# File patterns to exclude from Copilot suggestions
|
||||
exclude:
|
||||
- "**/node_modules/**"
|
||||
- "**/vendor/**"
|
||||
- "**/build/**"
|
||||
- "**/dist/**"
|
||||
- "**/.git/**"
|
||||
- "**/LICENSE"
|
||||
- "**/CHANGELOG.md"
|
||||
|
||||
# Notes:
|
||||
# ------
|
||||
# - This configuration allows GitHub Copilot to fetch information from trusted sources
|
||||
# - License providers are included to help with license text and compliance information
|
||||
# - Package registries help with dependency management and version checking
|
||||
# - Standards organizations provide authoritative specifications
|
||||
# - Platform-specific sites (Joomla, Dolibarr, PHP) support our technology stack
|
||||
# - All domains listed are well-known, reputable sources in their respective domains
|
||||
# - This list focuses on read-only access to public information
|
||||
# - No authentication credentials should be used with these domains
|
||||
@@ -1,76 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Workflows.Shared
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /.github/workflows/auto-assign.yml
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Auto-assign jmiller-moko to unassigned issues and PRs every 15 minutes
|
||||
|
||||
name: Auto-Assign Issues & PRs
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
schedule:
|
||||
- cron: '0 */12 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
name: Assign unassigned issues and PRs
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Assign unassigned issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
ASSIGNEE="jmiller-moko"
|
||||
|
||||
echo "## 🏷️ Auto-Assign Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
ASSIGNED_ISSUES=0
|
||||
ASSIGNED_PRS=0
|
||||
|
||||
# Assign unassigned open issues
|
||||
ISSUES=$(gh api "repos/$REPO/issues?state=open&per_page=100&assignee=none" --jq '.[].number' 2>/dev/null || true)
|
||||
for NUM in $ISSUES; do
|
||||
# Skip PRs (the issues endpoint returns PRs too)
|
||||
IS_PR=$(gh api "repos/$REPO/issues/$NUM" --jq '.pull_request // empty' 2>/dev/null || true)
|
||||
if [ -z "$IS_PR" ]; then
|
||||
gh api "repos/$REPO/issues/$NUM/assignees" -X POST -f "assignees[]=$ASSIGNEE" --silent 2>/dev/null && {
|
||||
ASSIGNED_ISSUES=$((ASSIGNED_ISSUES + 1))
|
||||
echo " Assigned issue #$NUM"
|
||||
} || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Assign unassigned open PRs
|
||||
PRS=$(gh api "repos/$REPO/pulls?state=open&per_page=100" --jq '.[] | select(.assignees | length == 0) | .number' 2>/dev/null || true)
|
||||
for NUM in $PRS; do
|
||||
gh api "repos/$REPO/issues/$NUM/assignees" -X POST -f "assignees[]=$ASSIGNEE" --silent 2>/dev/null && {
|
||||
ASSIGNED_PRS=$((ASSIGNED_PRS + 1))
|
||||
echo " Assigned PR #$NUM"
|
||||
} || true
|
||||
done
|
||||
|
||||
echo "| Type | Assigned |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Issues | $ASSIGNED_ISSUES |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Pull Requests | $ASSIGNED_PRS |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$ASSIGNED_ISSUES" -eq 0 ] && [ "$ASSIGNED_PRS" -eq 0 ]; then
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ All issues and PRs already have assignees" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -1,207 +0,0 @@
|
||||
# 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: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Automation
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/auto-dev-issue.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Auto-create tracking issue with sub-issues for dev/rc branch workflow
|
||||
# NOTE: Synced via bulk-repo-sync to .github/workflows/auto-dev-issue.yml in all governed repos.
|
||||
|
||||
name: Dev/RC Branch Issue
|
||||
|
||||
on:
|
||||
# Auto-create on RC branch creation
|
||||
create:
|
||||
# Manual trigger for dev branches
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch name (e.g., dev/my-feature or dev/04.06)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
create-issue:
|
||||
name: Create version tracking issue
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch') ||
|
||||
(github.event.ref_type == 'branch' &&
|
||||
(startsWith(github.event.ref, 'rc/') ||
|
||||
startsWith(github.event.ref, 'alpha/') ||
|
||||
startsWith(github.event.ref, 'beta/')))
|
||||
|
||||
steps:
|
||||
- name: Create tracking issue and sub-issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
# For manual dispatch, use input; for auto, use event ref
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
BRANCH="${{ inputs.branch }}"
|
||||
else
|
||||
BRANCH="${{ github.event.ref }}"
|
||||
fi
|
||||
REPO="${{ github.repository }}"
|
||||
ACTOR="${{ github.actor }}"
|
||||
NOW=$(date -u '+%Y-%m-%d %H:%M UTC')
|
||||
|
||||
# Determine branch type and version
|
||||
if [[ "$BRANCH" == rc/* ]]; then
|
||||
VERSION="${BRANCH#rc/}"
|
||||
BRANCH_TYPE="Release Candidate"
|
||||
LABEL_TYPE="type: release"
|
||||
TITLE_PREFIX="rc"
|
||||
elif [[ "$BRANCH" == beta/* ]]; then
|
||||
VERSION="${BRANCH#beta/}"
|
||||
BRANCH_TYPE="Beta"
|
||||
LABEL_TYPE="type: release"
|
||||
TITLE_PREFIX="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
VERSION="${BRANCH#alpha/}"
|
||||
BRANCH_TYPE="Alpha"
|
||||
LABEL_TYPE="type: release"
|
||||
TITLE_PREFIX="alpha"
|
||||
else
|
||||
VERSION="${BRANCH#dev/}"
|
||||
BRANCH_TYPE="Development"
|
||||
LABEL_TYPE="type: feature"
|
||||
TITLE_PREFIX="feat"
|
||||
fi
|
||||
|
||||
TITLE="${TITLE_PREFIX}(${VERSION}): ${BRANCH_TYPE} tracking for ${BRANCH}"
|
||||
|
||||
# Check for existing issue with same title prefix
|
||||
EXISTING=$(gh api "repos/${REPO}/issues?state=open&per_page=10" \
|
||||
--jq ".[] | select(.title | startswith(\"${TITLE_PREFIX}(${VERSION})\")) | .number" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "ℹ️ Issue #${EXISTING} already exists for ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Define sub-issues for the workflow ─────────────────────────
|
||||
if [[ "$BRANCH" == rc/* ]]; then
|
||||
SUB_ISSUES=(
|
||||
"RC Testing|Verify all features work on rc branch|type: test,release-candidate"
|
||||
"Regression Testing|Run full regression suite before merge|type: test,release-candidate"
|
||||
"Version Bump|Bump version in README.md and all headers|type: version,release-candidate"
|
||||
"Changelog Update|Update CHANGELOG.md with release notes|documentation,release-candidate"
|
||||
"Merge to Version Branch|Create PR to version/XX|type: release,needs-review"
|
||||
)
|
||||
elif [[ "$BRANCH" == alpha/* ]] || [[ "$BRANCH" == beta/* ]]; then
|
||||
SUB_ISSUES=(
|
||||
"Testing|Verify features on ${BRANCH_TYPE} branch|type: test,status: in-progress"
|
||||
"Bug Fixes|Fix issues found during ${BRANCH_TYPE} testing|type: bug,status: pending"
|
||||
"Promote to Next Stage|Create PR to promote to next release stage|type: release,needs-review"
|
||||
)
|
||||
else
|
||||
SUB_ISSUES=(
|
||||
"Development|Implement feature/fix on dev branch|type: feature,status: in-progress"
|
||||
"Unit Testing|Write and pass unit tests|type: test,status: pending"
|
||||
"Code Review|Request and complete code review|needs-review,status: pending"
|
||||
"Version Bump|Bump version in README.md and all headers|type: version,status: pending"
|
||||
"Changelog Update|Update CHANGELOG.md with release notes|documentation,status: pending"
|
||||
"Create RC Branch|Promote dev to rc branch for final testing|type: release,status: pending"
|
||||
"Merge to Main|Create PR from rc/dev to main|type: release,needs-review,status: pending"
|
||||
)
|
||||
fi
|
||||
|
||||
# ── Create sub-issues first ───────────────────────────────────────
|
||||
SUB_LIST=""
|
||||
SUB_NUMBERS=""
|
||||
for SUB in "${SUB_ISSUES[@]}"; do
|
||||
IFS='|' read -r SUB_TITLE SUB_DESC SUB_LABELS <<< "$SUB"
|
||||
SUB_FULL_TITLE="${TITLE_PREFIX}(${VERSION}): ${SUB_TITLE}"
|
||||
|
||||
SUB_BODY=$(printf '### %s\n\n%s\n\n| Field | Value |\n|-------|-------|\n| **Parent Branch** | `%s` |\n| **Version** | `%s` |\n\n---\n*Sub-issue of the %s tracking issue for `%s`.*' \
|
||||
"$SUB_TITLE" "$SUB_DESC" "$BRANCH" "$VERSION" "$BRANCH_TYPE" "$BRANCH")
|
||||
|
||||
SUB_URL=$(gh issue create \
|
||||
--repo "$REPO" \
|
||||
--title "$SUB_FULL_TITLE" \
|
||||
--body "$SUB_BODY" \
|
||||
--label "${SUB_LABELS}" \
|
||||
--assignee "jmiller-moko" 2>&1)
|
||||
|
||||
SUB_NUM=$(echo "$SUB_URL" | grep -oE '[0-9]+$')
|
||||
if [ -n "$SUB_NUM" ]; then
|
||||
SUB_LIST="${SUB_LIST}\n- [ ] ${SUB_TITLE} (#${SUB_NUM})"
|
||||
SUB_NUMBERS="${SUB_NUMBERS} #${SUB_NUM}"
|
||||
fi
|
||||
sleep 0.3
|
||||
done
|
||||
|
||||
# ── Create parent tracking issue ──────────────────────────────────
|
||||
PARENT_BODY=$(printf '## %s Branch Created\n\n| Field | Value |\n|-------|-------|\n| **Branch** | `%s` |\n| **Version** | `%s` |\n| **Type** | %s |\n| **Created by** | @%s |\n| **Created at** | %s |\n| **Repository** | `%s` |\n\n## Workflow Sub-Issues\n\n%b\n\n---\n*Auto-created by [auto-dev-issue.yml](.github/workflows/auto-dev-issue.yml) on branch creation.*' \
|
||||
"$BRANCH_TYPE" "$BRANCH" "$VERSION" "$BRANCH_TYPE" "$ACTOR" "$NOW" "$REPO" "$SUB_LIST")
|
||||
|
||||
PARENT_URL=$(gh issue create \
|
||||
--repo "$REPO" \
|
||||
--title "$TITLE" \
|
||||
--body "$PARENT_BODY" \
|
||||
--label "${LABEL_TYPE},version" \
|
||||
--assignee "jmiller-moko" 2>&1)
|
||||
|
||||
PARENT_NUM=$(echo "$PARENT_URL" | grep -oE '[0-9]+$')
|
||||
|
||||
# ── Link sub-issues back to parent ────────────────────────────────
|
||||
if [ -n "$PARENT_NUM" ]; then
|
||||
for SUB in "${SUB_ISSUES[@]}"; do
|
||||
IFS='|' read -r SUB_TITLE _ _ <<< "$SUB"
|
||||
SUB_FULL_TITLE="${TITLE_PREFIX}(${VERSION}): ${SUB_TITLE}"
|
||||
SUB_NUM=$(gh api "repos/${REPO}/issues?state=open&per_page=20" \
|
||||
--jq ".[] | select(.title == \"${SUB_FULL_TITLE}\") | .number" 2>/dev/null | head -1)
|
||||
if [ -n "$SUB_NUM" ]; then
|
||||
gh api "repos/${REPO}/issues/${SUB_NUM}" -X PATCH \
|
||||
-f body="$(gh api "repos/${REPO}/issues/${SUB_NUM}" --jq '.body' 2>/dev/null)
|
||||
|
||||
> **Parent Issue:** #${PARENT_NUM}" --silent 2>/dev/null || true
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Create or update prerelease for alpha/beta/rc ────────────────
|
||||
if [[ "$BRANCH" == rc/* ]] || [[ "$BRANCH" == alpha/* ]] || [[ "$BRANCH" == beta/* ]]; then
|
||||
case "$BRANCH_TYPE" in
|
||||
Alpha) RELEASE_TAG="alpha" ;;
|
||||
Beta) RELEASE_TAG="beta" ;;
|
||||
"Release Candidate") RELEASE_TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
EXISTING=$(gh release view "$RELEASE_TAG" --json tagName -q .tagName 2>/dev/null || true)
|
||||
if [ -z "$EXISTING" ]; then
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--title "${RELEASE_TAG} (${VERSION})" \
|
||||
--notes "## ${BRANCH_TYPE} ${VERSION}\n\nBranch: \`${BRANCH}\`\nTracking issue: ${PARENT_URL}" \
|
||||
--prerelease \
|
||||
--target main 2>/dev/null || true
|
||||
echo "${BRANCH_TYPE} release created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--title "${RELEASE_TAG} (${VERSION})" --prerelease 2>/dev/null || true
|
||||
echo "${BRANCH_TYPE} release updated: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────
|
||||
echo "## Dev Workflow Issues Created" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Item | Issue |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Parent** | ${PARENT_URL} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Sub-issues** |${SUB_NUMBERS} |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,542 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Release
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/joomla/auto-release.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Joomla build & release — ZIP package, updates.xml, SHA-256 checksum
|
||||
#
|
||||
# +========================================================================+
|
||||
# | BUILD & RELEASE PIPELINE (JOOMLA) |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Triggers on push to main (skips bot commits + [skip ci]): |
|
||||
# | |
|
||||
# | Every push: |
|
||||
# | 1. Read version from README.md |
|
||||
# | 3. Set platform version (Joomla <version>) |
|
||||
# | 4. Update [VERSION: XX.YY.ZZ] badges in markdown files |
|
||||
# | 5. Write updates.xml (Joomla update server XML) |
|
||||
# | 6. Create git tag vXX.YY.ZZ |
|
||||
# | 7a. Patch: update existing GitHub Release for this minor |
|
||||
# | 8. Build ZIP, upload asset, write SHA-256 to updates.xml |
|
||||
# | |
|
||||
# | Every version change: archives main -> version/XX.YY branch |
|
||||
# | Patch 00 = development (no release). First release = patch 01. |
|
||||
# | First release only (patch == 01): |
|
||||
# | 7b. Create new GitHub Release |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
github.actor != 'github-actions[bot]'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN || github.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch version/04 --quiet \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
|
||||
/tmp/mokostandards
|
||||
cd /tmp/mokostandards
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
# -- STEP 1: Read version -----------------------------------------------
|
||||
- name: "Step 1: Read version from README.md"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "No VERSION in README.md — skipping release"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Derive major.minor for branch naming (patches update existing branch)
|
||||
MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}')
|
||||
PATCH=$(echo "$VERSION" | awk -F. '{print $3}')
|
||||
|
||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
||||
MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}')
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
|
||||
echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$PATCH" = "00" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "is_minor=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION (patch 00 = development — skipping release)"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
if [ "$PATCH" = "01" ]; then
|
||||
echo "is_minor=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION (first release — full pipeline)"
|
||||
else
|
||||
echo "is_minor=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION (patch — platform version + badges only)"
|
||||
fi
|
||||
fi
|
||||
|
||||
- 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"
|
||||
|
||||
if [ "$TAG_EXISTS" = "true" ] && [ "$BRANCH_EXISTS" = "true" ]; then
|
||||
echo "already_released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "already_released=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# -- SANITY CHECKS -------------------------------------------------------
|
||||
- name: "Sanity: Pre-release validation"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
ERRORS=0
|
||||
|
||||
echo "## Pre-Release Sanity Checks (Joomla)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Version drift check (must pass before release) --------
|
||||
README_VER=$(grep -oP 'VERSION:\s*\K[\d.]+' README.md 2>/dev/null | head -1)
|
||||
if [ "$README_VER" != "$VERSION" ]; then
|
||||
echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Check CHANGELOG version matches
|
||||
CL_VER=$(grep -oP 'VERSION:\s*\K[\d.]+' CHANGELOG.md 2>/dev/null | head -1)
|
||||
if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
|
||||
echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
|
||||
# Check composer.json version if present
|
||||
if [ -f "composer.json" ]; then
|
||||
COMP_VER=$(grep -oP '"version"\s*:\s*"\K[^"]+' composer.json 2>/dev/null | head -1)
|
||||
if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
|
||||
echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Common checks
|
||||
if [ ! -f "LICENSE" ]; then
|
||||
echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
|
||||
echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- Joomla: manifest version drift --------
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
XML_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
|
||||
echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Joomla: XML manifest existence --------
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "- No Joomla XML manifest found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Joomla: extension type check --------
|
||||
TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null)
|
||||
echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 2: Create or update version/XX.YY archive branch ---------------
|
||||
# Always runs — every version change on main archives to version/XX.YY
|
||||
- name: "Step 2: Version archive branch"
|
||||
if: steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
IS_MINOR="${{ steps.version.outputs.is_minor }}"
|
||||
PATCH="${{ steps.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.version.outputs.version }}"
|
||||
php /tmp/mokostandards/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' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do
|
||||
if grep -q '\[VERSION:' "$f" 2>/dev/null; then
|
||||
sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# -- STEP 5: Write updates.xml (Joomla update server) ---------------------
|
||||
- name: "Step 5: Write updates.xml"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# -- Parse extension metadata from XML manifest ----------------
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EXT_NAME=$(grep -oP '<name>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
|
||||
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "")
|
||||
EXT_CLIENT=$(grep -oP '<extension[^>]+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
||||
EXT_FOLDER=$(grep -oP '<extension[^>]+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
||||
TARGET_PLATFORM=$(grep -oP '<targetplatform[^/]*/>' "$MANIFEST" 2>/dev/null | head -1 || echo "")
|
||||
PHP_MINIMUM=$(grep -oP '<php_minimum>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "")
|
||||
|
||||
# Derive element from manifest filename if not in XML
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml)
|
||||
fi
|
||||
|
||||
# Build client tag: plugins and frontend modules need <client>site</client>
|
||||
CLIENT_TAG=""
|
||||
if [ -n "$EXT_CLIENT" ]; then
|
||||
CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||
elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
|
||||
CLIENT_TAG="<client>site</client>"
|
||||
fi
|
||||
|
||||
# Build folder tag for plugins (required for Joomla to match the update)
|
||||
FOLDER_TAG=""
|
||||
if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
|
||||
FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
||||
fi
|
||||
|
||||
# Build targetplatform (fallback to Joomla 5 if not in manifest)
|
||||
if [ -z "$TARGET_PLATFORM" ]; then
|
||||
TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="5.*" %s>' "/")
|
||||
fi
|
||||
|
||||
# Build php_minimum tag
|
||||
PHP_TAG=""
|
||||
if [ -n "$PHP_MINIMUM" ]; then
|
||||
PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/v${VERSION}/${EXT_ELEMENT}-${VERSION}.zip"
|
||||
INFO_URL="https://github.com/${REPO}/releases/tag/v${VERSION}"
|
||||
|
||||
# -- Build stable entry to temp file ─────────────────────────
|
||||
{
|
||||
printf '%s\n' ' <update>'
|
||||
printf '%s\n' " <name>${EXT_NAME}</name>"
|
||||
printf '%s\n' " <description>${EXT_NAME} update</description>"
|
||||
printf '%s\n' " <element>${EXT_ELEMENT}</element>"
|
||||
printf '%s\n' " <type>${EXT_TYPE}</type>"
|
||||
printf '%s\n' " <version>${VERSION}</version>"
|
||||
[ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
|
||||
[ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
|
||||
printf '%s\n' ' <tags>'
|
||||
printf '%s\n' ' <tag>stable</tag>'
|
||||
printf '%s\n' ' </tags>'
|
||||
printf '%s\n' " <infourl title=\"${EXT_NAME}\">${INFO_URL}</infourl>"
|
||||
printf '%s\n' ' <downloads>'
|
||||
printf '%s\n' " <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>"
|
||||
printf '%s\n' ' </downloads>'
|
||||
printf '%s\n' " ${TARGET_PLATFORM}"
|
||||
[ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}"
|
||||
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
|
||||
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
|
||||
printf '%s\n' ' </update>'
|
||||
} > /tmp/stable_entry.xml
|
||||
|
||||
# -- Write updates.xml preserving dev/rc entries ──────────────
|
||||
RC_ENTRY=""
|
||||
DEV_ENTRY=""
|
||||
if [ -f "updates.xml" ]; then
|
||||
printf 'import re\n' > /tmp/extract.py
|
||||
printf 'with open("updates.xml") as f: c = f.read()\n' >> /tmp/extract.py
|
||||
printf 'import sys; tag = sys.argv[1]\n' >> /tmp/extract.py
|
||||
printf 'm = re.search(r"( <update>.*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)", c, re.DOTALL)\n' >> /tmp/extract.py
|
||||
printf 'if m: print(m.group(1))\n' >> /tmp/extract.py
|
||||
RC_ENTRY=$(python3 /tmp/extract.py rc 2>/dev/null || true)
|
||||
DEV_ENTRY=$(python3 /tmp/extract.py development 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>'
|
||||
printf '%s\n' '<updates>'
|
||||
cat /tmp/stable_entry.xml
|
||||
[ -n "$RC_ENTRY" ] && echo "$RC_ENTRY"
|
||||
[ -n "$DEV_ENTRY" ] && echo "$DEV_ENTRY"
|
||||
printf '%s\n' '</updates>'
|
||||
} > updates.xml
|
||||
|
||||
echo "updates.xml: ${VERSION} (stable + rc/dev preserved)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Commit all changes ---------------------------------------------------
|
||||
- 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.version.outputs.version }}"
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add -A
|
||||
git commit -m "chore(release): build ${VERSION} [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
|
||||
# -- STEP 6: Create tag ---------------------------------------------------
|
||||
- name: "Step 6: Create git tag"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.tag_exists != 'true' &&
|
||||
steps.version.outputs.is_minor == 'true'
|
||||
run: |
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
# Only create the major release tag if it doesn't exist yet
|
||||
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
git tag "$RELEASE_TAG"
|
||||
git push origin "$RELEASE_TAG"
|
||||
echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 7: Create or update GitHub Release ------------------------------
|
||||
- name: "Step 7: GitHub Release"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.tag_exists != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
MAJOR="${{ steps.version.outputs.major }}"
|
||||
|
||||
NOTES=$(php /tmp/mokostandards/api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
echo "$NOTES" > /tmp/release_notes.md
|
||||
|
||||
# Check if the major release already exists
|
||||
EXISTING=$(gh release view "$RELEASE_TAG" --json tagName -q .tagName 2>/dev/null || true)
|
||||
|
||||
if [ -z "$EXISTING" ]; then
|
||||
# First release for this major
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" \
|
||||
--notes-file /tmp/release_notes.md \
|
||||
--target "$BRANCH"
|
||||
echo "Release created: ${RELEASE_TAG} (${VERSION})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Append version notes to existing major release
|
||||
CURRENT_NOTES=$(gh release view "$RELEASE_TAG" --json body -q .body 2>/dev/null || true)
|
||||
{
|
||||
echo "$CURRENT_NOTES"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "### ${VERSION}"
|
||||
echo ""
|
||||
cat /tmp/release_notes.md
|
||||
} > /tmp/updated_notes.md
|
||||
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" \
|
||||
--notes-file /tmp/updated_notes.md
|
||||
echo "Release updated: ${RELEASE_TAG} -> ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
|
||||
# Every patch builds an install-ready ZIP and uploads it to the minor release.
|
||||
# Result: one Release per minor version with a ZIP for each patch.
|
||||
- name: "Step 8: Build Joomla package and update checksum"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# All ZIPs upload to the major release tag (vXX)
|
||||
gh release view "$RELEASE_TAG" --json tagName > /dev/null 2>&1 || {
|
||||
echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Find extension element name from manifest
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
|
||||
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
|
||||
PACKAGE_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||
|
||||
# -- Build install-ready ZIP from src/ ----------------------------
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — skipping package"; exit 0; }
|
||||
|
||||
cd "$SOURCE_DIR"
|
||||
zip -r "/tmp/${PACKAGE_NAME}" . -x '.ftpignore' 'sftp-config*' '*.ppk' '*.pem' '*.key' '.env*'
|
||||
cd ..
|
||||
|
||||
FILESIZE=$(stat -c%s "/tmp/${PACKAGE_NAME}" 2>/dev/null || stat -f%z "/tmp/${PACKAGE_NAME}" 2>/dev/null || echo "unknown")
|
||||
|
||||
# -- Calculate SHA-256 -------------------------------------------
|
||||
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# -- Upload ZIP to the minor release tag -------------------------
|
||||
gh release upload "$RELEASE_TAG" "/tmp/${PACKAGE_NAME}" --clobber 2>/dev/null || {
|
||||
echo "Could not upload with --clobber, retrying..."
|
||||
gh release upload "$RELEASE_TAG" "/tmp/${PACKAGE_NAME}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# -- Update updates.xml with SHA-256 for latest patch -------------
|
||||
if [ -f "updates.xml" ]; then
|
||||
if grep -q '<sha256>' updates.xml; then
|
||||
sed -i "s|<sha256>.*</sha256>|<sha256>sha256:${SHA256}</sha256>|" updates.xml
|
||||
else
|
||||
sed -i "s|</downloads>|</downloads>\n <sha256>sha256:${SHA256}</sha256>|" updates.xml
|
||||
fi
|
||||
|
||||
# Also update the download URL to point to this patch's ZIP
|
||||
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
||||
sed -i "s|<downloadurl[^>]*>[^<]*</downloadurl>|<downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>|" updates.xml
|
||||
|
||||
git add updates.xml
|
||||
git commit -m "chore(release): SHA-256 + download URL for ${VERSION} [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>" || true
|
||||
git push || true
|
||||
fi
|
||||
|
||||
echo "### Joomla Package" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${PACKAGE_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Size | ${FILESIZE} bytes |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | \`${RELEASE_TAG}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [${PACKAGE_NAME}](https://github.com/${REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}) |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
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 (Joomla)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $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](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -1,101 +0,0 @@
|
||||
# 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: GitHub.Workflow.Template
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/changelog-validation.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Validates CHANGELOG.md format and version consistency
|
||||
# NOTE: Deployed to .github/workflows/changelog-validation.yml in governed repos.
|
||||
|
||||
name: Changelog Validation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
validate-changelog:
|
||||
name: Validate CHANGELOG.md
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Check CHANGELOG.md exists
|
||||
run: |
|
||||
echo "### Changelog Validation" >> $GITHUB_STEP_SUMMARY
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "CHANGELOG.md not found in repository root." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "CHANGELOG.md exists." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Check VERSION header matches README.md
|
||||
run: |
|
||||
# Extract version from README.md FILE INFORMATION block
|
||||
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
|
||||
if [ -z "$README_VERSION" ]; then
|
||||
echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check that CHANGELOG.md has a matching version header
|
||||
CHANGELOG_VERSION=$(grep -oP '^\#\#\s*\[\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' CHANGELOG.md | head -1)
|
||||
if [ -z "$CHANGELOG_VERSION" ]; then
|
||||
echo "No version header found in CHANGELOG.md (expected \`## [XX.YY.ZZ] - YYYY-MM-DD\`)." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$CHANGELOG_VERSION" != "$README_VERSION" ]; then
|
||||
echo "CHANGELOG latest version \`${CHANGELOG_VERSION}\` does not match README VERSION \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "CHANGELOG version \`${CHANGELOG_VERSION}\` matches README VERSION." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Validate conventional changelog format
|
||||
run: |
|
||||
ERRORS=0
|
||||
|
||||
# Check that version entries follow ## [XX.YY.ZZ] - YYYY-MM-DD format
|
||||
while IFS= read -r LINE; do
|
||||
if ! echo "$LINE" | grep -qP '^\#\#\s*\[[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]\s*-\s*[0-9]{4}-[0-9]{2}-[0-9]{2}'; then
|
||||
echo "Malformed version header: \`${LINE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo " Expected format: \`## [XX.YY.ZZ] - YYYY-MM-DD\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(grep -P '^\#\#\s*\[' CHANGELOG.md)
|
||||
|
||||
ENTRY_COUNT=$(grep -cP '^\#\#\s*\[' CHANGELOG.md || echo "0")
|
||||
if [ "$ENTRY_COUNT" -eq 0 ]; then
|
||||
echo "No version entries found in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Found ${ENTRY_COUNT} version entr(ies) in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} format issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Changelog format validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -1,115 +0,0 @@
|
||||
# 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: GitHub.Workflow.Template
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/generic/codeql-analysis.yml.template
|
||||
# VERSION: 04.05.00
|
||||
# BRIEF: CodeQL security scanning workflow (generic — all repo types)
|
||||
# NOTE: Deployed to .github/workflows/codeql-analysis.yml in governed repos.
|
||||
# CodeQL does not support PHP directly; JavaScript scans JSON/YAML/shell.
|
||||
# For PHP-specific security scanning see standards-compliance.yml.
|
||||
|
||||
name: CodeQL Security Scanning
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev/**
|
||||
- rc/**
|
||||
- version/**
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev/**
|
||||
- rc/**
|
||||
schedule:
|
||||
# Weekly on Monday at 06:00 UTC
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 360
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# CodeQL does not support PHP. Use 'javascript' to scan JSON, YAML,
|
||||
# and shell scripts. Add 'actions' to scan GitHub Actions workflows.
|
||||
language: ['javascript', 'actions']
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
upload: true
|
||||
output: sarif-results
|
||||
wait-for-processing: true
|
||||
|
||||
- name: Upload SARIF results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.5.0
|
||||
with:
|
||||
name: codeql-results-${{ matrix.language }}
|
||||
path: sarif-results
|
||||
retention-days: 30
|
||||
|
||||
- name: Step summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### 🔍 CodeQL — ${{ matrix.language }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
URL="https://github.com/${{ github.repository }}/security/code-scanning"
|
||||
echo "See the [Security tab]($URL) for findings." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Severity | SLA |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|-----|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Critical | 7 days |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| High | 14 days |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Medium | 30 days |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Low | 60 days / next release |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
summary:
|
||||
name: Security Scan Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: analyze
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### 🛡️ CodeQL Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Trigger:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
SECURITY_URL="https://github.com/${{ github.repository }}/security"
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📊 [View all security alerts]($SECURITY_URL)" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,758 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# This file is part of a Moko Consulting project.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Firewall
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/enterprise-firewall-setup.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Enterprise firewall configuration — generates outbound allow-rules including SFTP deployment server
|
||||
# NOTE: Reads DEV_FTP_HOST / DEV_FTP_PORT variables to include SFTP egress rules alongside HTTPS rules.
|
||||
|
||||
name: Enterprise Firewall Configuration
|
||||
|
||||
# This workflow provides firewall configuration guidance for enterprise-ready sites
|
||||
# It generates firewall rules for allowing outbound access to trusted domains
|
||||
# including license providers, documentation sources, package registries,
|
||||
# and the SFTP deployment server (DEV_FTP_HOST / DEV_FTP_PORT).
|
||||
#
|
||||
# Runs automatically when:
|
||||
# - Coding agent workflows are triggered (pull requests with copilot/ prefix)
|
||||
# - Manual workflow dispatch for custom configurations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
firewall_type:
|
||||
description: 'Target firewall type'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- 'iptables'
|
||||
- 'ufw'
|
||||
- 'firewalld'
|
||||
- 'aws-security-group'
|
||||
- 'azure-nsg'
|
||||
- 'gcp-firewall'
|
||||
- 'cloudflare'
|
||||
- 'all'
|
||||
default: 'all'
|
||||
output_format:
|
||||
description: 'Output format'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- 'shell-script'
|
||||
- 'json'
|
||||
- 'yaml'
|
||||
- 'markdown'
|
||||
- 'all'
|
||||
default: 'markdown'
|
||||
|
||||
# Auto-run when coding agent creates or updates PRs
|
||||
pull_request:
|
||||
branches:
|
||||
- 'copilot/**'
|
||||
- 'agent/**'
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Auto-run on push to coding agent branches
|
||||
push:
|
||||
branches:
|
||||
- 'copilot/**'
|
||||
- 'agent/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
generate-firewall-rules:
|
||||
name: Generate Firewall Rules
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Apply Firewall Rules to Runner (Auto-run only)
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
env:
|
||||
DEV_FTP_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_FTP_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
echo "🔥 Applying firewall rules for coding agent environment..."
|
||||
echo ""
|
||||
echo "This step ensures the GitHub Actions runner can access trusted domains"
|
||||
echo "including license providers, package registries, and documentation sources."
|
||||
echo ""
|
||||
|
||||
# Note: GitHub Actions runners are ephemeral and run in controlled environments
|
||||
# This step documents what domains are being accessed during the workflow
|
||||
# Actual firewall configuration is managed by GitHub
|
||||
|
||||
cat > /tmp/trusted-domains.txt << 'EOF'
|
||||
# Trusted domains for coding agent environment
|
||||
# License Providers
|
||||
www.gnu.org
|
||||
opensource.org
|
||||
choosealicense.com
|
||||
spdx.org
|
||||
creativecommons.org
|
||||
apache.org
|
||||
fsf.org
|
||||
|
||||
# Documentation & Standards
|
||||
semver.org
|
||||
keepachangelog.com
|
||||
conventionalcommits.org
|
||||
|
||||
# GitHub & Related
|
||||
github.com
|
||||
api.github.com
|
||||
docs.github.com
|
||||
raw.githubusercontent.com
|
||||
ghcr.io
|
||||
|
||||
# Package Registries
|
||||
npmjs.com
|
||||
registry.npmjs.org
|
||||
pypi.org
|
||||
files.pythonhosted.org
|
||||
packagist.org
|
||||
repo.packagist.org
|
||||
rubygems.org
|
||||
|
||||
# Platform-Specific
|
||||
joomla.org
|
||||
downloads.joomla.org
|
||||
docs.joomla.org
|
||||
php.net
|
||||
getcomposer.org
|
||||
dolibarr.org
|
||||
wiki.dolibarr.org
|
||||
docs.dolibarr.org
|
||||
|
||||
# Moko Consulting
|
||||
mokoconsulting.tech
|
||||
|
||||
# SFTP Deployment Server (DEV_FTP_HOST)
|
||||
${DEV_FTP_HOST:-<not configured>}
|
||||
|
||||
# Google Services
|
||||
drive.google.com
|
||||
docs.google.com
|
||||
sheets.google.com
|
||||
accounts.google.com
|
||||
storage.googleapis.com
|
||||
fonts.googleapis.com
|
||||
fonts.gstatic.com
|
||||
|
||||
# GitHub Extended
|
||||
upload.github.com
|
||||
objects.githubusercontent.com
|
||||
user-images.githubusercontent.com
|
||||
codeload.github.com
|
||||
pkg.github.com
|
||||
|
||||
# Developer Reference
|
||||
developer.mozilla.org
|
||||
stackoverflow.com
|
||||
git-scm.com
|
||||
|
||||
# CDN & Infrastructure
|
||||
cdn.jsdelivr.net
|
||||
unpkg.com
|
||||
cdnjs.cloudflare.com
|
||||
img.shields.io
|
||||
|
||||
# Container Registries
|
||||
hub.docker.com
|
||||
registry-1.docker.io
|
||||
|
||||
# CI & Code Quality
|
||||
codecov.io
|
||||
sonarcloud.io
|
||||
|
||||
# Terraform & Infrastructure
|
||||
registry.terraform.io
|
||||
releases.hashicorp.com
|
||||
checkpoint-api.hashicorp.com
|
||||
EOF
|
||||
|
||||
echo "✓ Trusted domains documented for this runner"
|
||||
echo "✓ GitHub Actions runners have network access to these domains"
|
||||
echo ""
|
||||
|
||||
# Test connectivity to key domains
|
||||
echo "Testing connectivity to key domains..."
|
||||
for domain in "github.com" "www.gnu.org" "npmjs.com" "pypi.org"; do
|
||||
if curl -s --max-time 3 -o /dev/null -w "%{http_code}" "https://$domain" | grep -q "200\|301\|302"; then
|
||||
echo " ✓ $domain is accessible"
|
||||
else
|
||||
echo " ⚠️ $domain connectivity check failed (may be expected)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Test SFTP server connectivity (TCP port check)
|
||||
SFTP_HOST="${DEV_FTP_HOST:-}"
|
||||
SFTP_PORT="${DEV_FTP_PORT:-22}"
|
||||
if [ -n "$SFTP_HOST" ]; then
|
||||
# Strip any embedded :port suffix
|
||||
SFTP_HOST="${SFTP_HOST%%:*}"
|
||||
echo ""
|
||||
echo "Testing SFTP deployment server connectivity..."
|
||||
if timeout 5 bash -c "echo >/dev/tcp/${SFTP_HOST}/${SFTP_PORT}" 2>/dev/null; then
|
||||
echo " ✓ SFTP server ${SFTP_HOST}:${SFTP_PORT} is reachable"
|
||||
else
|
||||
echo " ⚠️ SFTP server ${SFTP_HOST}:${SFTP_PORT} is not reachable from runner (firewall rule needed)"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo " ℹ️ DEV_FTP_HOST not configured — skipping SFTP connectivity check"
|
||||
fi
|
||||
|
||||
- name: Generate Firewall Configuration
|
||||
id: generate
|
||||
env:
|
||||
DEV_FTP_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_FTP_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
cat > generate_firewall_config.py << 'PYTHON_EOF'
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enterprise Firewall Configuration Generator
|
||||
|
||||
Generates firewall rules for enterprise-ready deployments allowing
|
||||
access to trusted domains including license providers, documentation
|
||||
sources, package registries, and platform-specific sites.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import yaml
|
||||
import sys
|
||||
from typing import List, Dict
|
||||
|
||||
# SFTP deployment server from org variables
|
||||
_sftp_host_raw = os.environ.get("DEV_FTP_HOST", "").strip()
|
||||
_sftp_port = os.environ.get("DEV_FTP_PORT", "").strip() or "22"
|
||||
# Strip embedded :port suffix if present
|
||||
_sftp_host = _sftp_host_raw.split(":")[0] if _sftp_host_raw else ""
|
||||
if ":" in _sftp_host_raw and not _sftp_port:
|
||||
_sftp_port = _sftp_host_raw.split(":")[1]
|
||||
|
||||
SFTP_HOST = _sftp_host
|
||||
SFTP_PORT = int(_sftp_port) if _sftp_port.isdigit() else 22
|
||||
|
||||
# Trusted domains from .github/copilot.yml
|
||||
TRUSTED_DOMAINS = {
|
||||
"license_providers": [
|
||||
"www.gnu.org",
|
||||
"opensource.org",
|
||||
"choosealicense.com",
|
||||
"spdx.org",
|
||||
"creativecommons.org",
|
||||
"apache.org",
|
||||
"fsf.org",
|
||||
],
|
||||
"documentation_standards": [
|
||||
"semver.org",
|
||||
"keepachangelog.com",
|
||||
"conventionalcommits.org",
|
||||
],
|
||||
"github_related": [
|
||||
"github.com",
|
||||
"api.github.com",
|
||||
"docs.github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"ghcr.io",
|
||||
],
|
||||
"package_registries": [
|
||||
"npmjs.com",
|
||||
"registry.npmjs.org",
|
||||
"pypi.org",
|
||||
"files.pythonhosted.org",
|
||||
"packagist.org",
|
||||
"repo.packagist.org",
|
||||
"rubygems.org",
|
||||
],
|
||||
"standards_organizations": [
|
||||
"json-schema.org",
|
||||
"w3.org",
|
||||
"ietf.org",
|
||||
],
|
||||
"platform_specific": [
|
||||
"joomla.org",
|
||||
"downloads.joomla.org",
|
||||
"docs.joomla.org",
|
||||
"php.net",
|
||||
"getcomposer.org",
|
||||
"dolibarr.org",
|
||||
"wiki.dolibarr.org",
|
||||
"docs.dolibarr.org",
|
||||
],
|
||||
"moko_consulting": [
|
||||
"mokoconsulting.tech",
|
||||
],
|
||||
"google_services": [
|
||||
"drive.google.com",
|
||||
"docs.google.com",
|
||||
"sheets.google.com",
|
||||
"accounts.google.com",
|
||||
"storage.googleapis.com",
|
||||
"fonts.googleapis.com",
|
||||
"fonts.gstatic.com",
|
||||
],
|
||||
"github_extended": [
|
||||
"upload.github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"user-images.githubusercontent.com",
|
||||
"codeload.github.com",
|
||||
"pkg.github.com",
|
||||
],
|
||||
"developer_reference": [
|
||||
"developer.mozilla.org",
|
||||
"stackoverflow.com",
|
||||
"git-scm.com",
|
||||
],
|
||||
"cdn_and_infrastructure": [
|
||||
"cdn.jsdelivr.net",
|
||||
"unpkg.com",
|
||||
"cdnjs.cloudflare.com",
|
||||
"img.shields.io",
|
||||
],
|
||||
"container_registries": [
|
||||
"hub.docker.com",
|
||||
"registry-1.docker.io",
|
||||
],
|
||||
"ci_code_quality": [
|
||||
"codecov.io",
|
||||
"sonarcloud.io",
|
||||
],
|
||||
"terraform_infrastructure": [
|
||||
"registry.terraform.io",
|
||||
"releases.hashicorp.com",
|
||||
"checkpoint-api.hashicorp.com",
|
||||
],
|
||||
}
|
||||
|
||||
# Inject SFTP deployment server as a separate category (port 22, not 443)
|
||||
if SFTP_HOST:
|
||||
TRUSTED_DOMAINS["sftp_deployment_server"] = [SFTP_HOST]
|
||||
print(f"ℹ️ SFTP deployment server: {SFTP_HOST}:{SFTP_PORT}")
|
||||
|
||||
def generate_sftp_iptables_rules(host: str, port: int) -> str:
|
||||
"""Generate iptables rules specifically for SFTP egress"""
|
||||
return (
|
||||
f"# Allow SFTP to deployment server {host}:{port}\n"
|
||||
f"iptables -A OUTPUT -p tcp -d $(dig +short {host} | head -1)"
|
||||
f" --dport {port} -j ACCEPT # SFTP deploy\n"
|
||||
)
|
||||
|
||||
def generate_sftp_ufw_rules(host: str, port: int) -> str:
|
||||
"""Generate UFW rules for SFTP egress"""
|
||||
return (
|
||||
f"# Allow SFTP to deployment server\n"
|
||||
f"ufw allow out to $(dig +short {host} | head -1)"
|
||||
f" port {port} proto tcp comment 'SFTP deploy to {host}'\n"
|
||||
)
|
||||
|
||||
def generate_sftp_firewalld_rules(host: str, port: int) -> str:
|
||||
"""Generate firewalld rules for SFTP egress"""
|
||||
return (
|
||||
f"# Allow SFTP to deployment server\n"
|
||||
f"firewall-cmd --permanent --add-rich-rule='"
|
||||
f"rule family=ipv4 destination address=$(dig +short {host} | head -1)"
|
||||
f" port port={port} protocol=tcp accept' # SFTP deploy\n"
|
||||
)
|
||||
|
||||
def generate_iptables_rules(domains: List[str]) -> str:
|
||||
"""Generate iptables firewall rules"""
|
||||
rules = ["#!/bin/bash", "", "# Enterprise Firewall Rules - iptables", ""]
|
||||
rules.append("# Allow outbound HTTPS to trusted domains")
|
||||
rules.append("")
|
||||
|
||||
for domain in domains:
|
||||
rules.append(f"# Allow {domain}")
|
||||
rules.append(f"iptables -A OUTPUT -p tcp -d $(dig +short {domain} | head -1) --dport 443 -j ACCEPT")
|
||||
|
||||
rules.append("")
|
||||
rules.append("# Allow DNS lookups")
|
||||
rules.append("iptables -A OUTPUT -p udp --dport 53 -j ACCEPT")
|
||||
rules.append("iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT")
|
||||
|
||||
return "\n".join(rules)
|
||||
|
||||
def generate_ufw_rules(domains: List[str]) -> str:
|
||||
"""Generate UFW firewall rules"""
|
||||
rules = ["#!/bin/bash", "", "# Enterprise Firewall Rules - UFW", ""]
|
||||
rules.append("# Allow outbound HTTPS to trusted domains")
|
||||
rules.append("")
|
||||
|
||||
for domain in domains:
|
||||
rules.append(f"# Allow {domain}")
|
||||
rules.append(f"ufw allow out to $(dig +short {domain} | head -1) port 443 proto tcp comment 'Allow {domain}'")
|
||||
|
||||
rules.append("")
|
||||
rules.append("# Allow DNS")
|
||||
rules.append("ufw allow out 53/udp comment 'Allow DNS UDP'")
|
||||
rules.append("ufw allow out 53/tcp comment 'Allow DNS TCP'")
|
||||
|
||||
return "\n".join(rules)
|
||||
|
||||
def generate_firewalld_rules(domains: List[str]) -> str:
|
||||
"""Generate firewalld rules"""
|
||||
rules = ["#!/bin/bash", "", "# Enterprise Firewall Rules - firewalld", ""]
|
||||
rules.append("# Add trusted domains to firewall")
|
||||
rules.append("")
|
||||
|
||||
for domain in domains:
|
||||
rules.append(f"# Allow {domain}")
|
||||
rules.append(f"firewall-cmd --permanent --add-rich-rule='rule family=ipv4 destination address=$(dig +short {domain} | head -1) port port=443 protocol=tcp accept'")
|
||||
|
||||
rules.append("")
|
||||
rules.append("# Reload firewall")
|
||||
rules.append("firewall-cmd --reload")
|
||||
|
||||
return "\n".join(rules)
|
||||
|
||||
def generate_aws_security_group(domains: List[str]) -> Dict:
|
||||
"""Generate AWS Security Group rules (JSON format)"""
|
||||
rules = {
|
||||
"SecurityGroupRules": {
|
||||
"Egress": []
|
||||
}
|
||||
}
|
||||
|
||||
for domain in domains:
|
||||
rules["SecurityGroupRules"]["Egress"].append({
|
||||
"Description": f"Allow HTTPS to {domain}",
|
||||
"IpProtocol": "tcp",
|
||||
"FromPort": 443,
|
||||
"ToPort": 443,
|
||||
"CidrIp": "0.0.0.0/0", # In practice, resolve to specific IPs
|
||||
"Tags": [{
|
||||
"Key": "Domain",
|
||||
"Value": domain
|
||||
}]
|
||||
})
|
||||
|
||||
# Add DNS
|
||||
rules["SecurityGroupRules"]["Egress"].append({
|
||||
"Description": "Allow DNS",
|
||||
"IpProtocol": "udp",
|
||||
"FromPort": 53,
|
||||
"ToPort": 53,
|
||||
"CidrIp": "0.0.0.0/0"
|
||||
})
|
||||
|
||||
return rules
|
||||
|
||||
def generate_markdown_documentation(domains_by_category: Dict[str, List[str]]) -> str:
|
||||
"""Generate markdown documentation"""
|
||||
md = ["# Enterprise Firewall Configuration Guide", ""]
|
||||
md.append("## Overview")
|
||||
md.append("")
|
||||
md.append("This document provides firewall configuration guidance for enterprise-ready deployments.")
|
||||
md.append("It lists trusted domains that should be whitelisted for outbound access to ensure")
|
||||
md.append("proper functionality of license validation, package management, and documentation access.")
|
||||
md.append("")
|
||||
|
||||
md.append("## Trusted Domains by Category")
|
||||
md.append("")
|
||||
|
||||
all_domains = []
|
||||
for category, domains in domains_by_category.items():
|
||||
category_name = category.replace("_", " ").title()
|
||||
md.append(f"### {category_name}")
|
||||
md.append("")
|
||||
md.append("| Domain | Purpose |")
|
||||
md.append("|--------|---------|")
|
||||
|
||||
for domain in domains:
|
||||
all_domains.append(domain)
|
||||
purpose = get_domain_purpose(domain)
|
||||
md.append(f"| `{domain}` | {purpose} |")
|
||||
|
||||
md.append("")
|
||||
|
||||
md.append("## Implementation Examples")
|
||||
md.append("")
|
||||
|
||||
md.append("### iptables Example")
|
||||
md.append("")
|
||||
md.append("```bash")
|
||||
md.append("# Allow HTTPS to trusted domain")
|
||||
md.append(f"iptables -A OUTPUT -p tcp -d $(dig +short {all_domains[0]}) --dport 443 -j ACCEPT")
|
||||
md.append("```")
|
||||
md.append("")
|
||||
|
||||
md.append("### UFW Example")
|
||||
md.append("")
|
||||
md.append("```bash")
|
||||
md.append("# Allow HTTPS to trusted domain")
|
||||
md.append(f"ufw allow out to {all_domains[0]} port 443 proto tcp")
|
||||
md.append("```")
|
||||
md.append("")
|
||||
|
||||
md.append("### AWS Security Group Example")
|
||||
md.append("")
|
||||
md.append("```json")
|
||||
md.append("{")
|
||||
md.append(' "IpPermissions": [{')
|
||||
md.append(' "IpProtocol": "tcp",')
|
||||
md.append(' "FromPort": 443,')
|
||||
md.append(' "ToPort": 443,')
|
||||
md.append(' "IpRanges": [{"CidrIp": "0.0.0.0/0", "Description": "HTTPS to trusted domains"}]')
|
||||
md.append(" }]")
|
||||
md.append("}")
|
||||
md.append("```")
|
||||
md.append("")
|
||||
|
||||
md.append("## Ports Required")
|
||||
md.append("")
|
||||
md.append("| Port | Protocol | Purpose |")
|
||||
md.append("|------|----------|---------|")
|
||||
md.append("| 443 | TCP | HTTPS (secure web access) |")
|
||||
md.append("| 80 | TCP | HTTP (redirects to HTTPS) |")
|
||||
md.append("| 53 | UDP/TCP | DNS resolution |")
|
||||
md.append("")
|
||||
|
||||
md.append("## Security Considerations")
|
||||
md.append("")
|
||||
md.append("1. **DNS Resolution**: Ensure DNS queries are allowed (port 53 UDP/TCP)")
|
||||
md.append("2. **Certificate Validation**: HTTPS requires ability to reach certificate authorities")
|
||||
md.append("3. **Dynamic IPs**: Some domains use CDNs with dynamic IPs - consider using FQDNs in rules")
|
||||
md.append("4. **Regular Updates**: Review and update whitelist as services change")
|
||||
md.append("5. **Logging**: Enable logging for blocked connections to identify missing rules")
|
||||
md.append("")
|
||||
|
||||
md.append("## Compliance Notes")
|
||||
md.append("")
|
||||
md.append("- All listed domains provide read-only access to public information")
|
||||
md.append("- License providers enable GPL compliance verification")
|
||||
md.append("- Package registries support dependency security scanning")
|
||||
md.append("- No authentication credentials are transmitted to these domains")
|
||||
md.append("")
|
||||
|
||||
return "\n".join(md)
|
||||
|
||||
def get_domain_purpose(domain: str) -> str:
|
||||
"""Get human-readable purpose for a domain"""
|
||||
purposes = {
|
||||
"www.gnu.org": "GNU licenses and documentation",
|
||||
"opensource.org": "Open Source Initiative resources",
|
||||
"choosealicense.com": "GitHub license selection tool",
|
||||
"spdx.org": "Software Package Data Exchange identifiers",
|
||||
"creativecommons.org": "Creative Commons licenses",
|
||||
"apache.org": "Apache Software Foundation licenses",
|
||||
"fsf.org": "Free Software Foundation resources",
|
||||
"semver.org": "Semantic versioning specification",
|
||||
"keepachangelog.com": "Changelog format standards",
|
||||
"conventionalcommits.org": "Commit message conventions",
|
||||
"github.com": "GitHub platform access",
|
||||
"api.github.com": "GitHub API access",
|
||||
"docs.github.com": "GitHub documentation",
|
||||
"raw.githubusercontent.com": "GitHub raw content access",
|
||||
"npmjs.com": "npm package registry",
|
||||
"pypi.org": "Python Package Index",
|
||||
"packagist.org": "PHP Composer package registry",
|
||||
"rubygems.org": "Ruby gems registry",
|
||||
"joomla.org": "Joomla CMS platform",
|
||||
"php.net": "PHP documentation and downloads",
|
||||
"dolibarr.org": "Dolibarr ERP/CRM platform",
|
||||
}
|
||||
return purposes.get(domain, "Trusted resource")
|
||||
|
||||
def main():
|
||||
# Use inputs if provided (manual dispatch), otherwise use defaults (auto-run)
|
||||
firewall_type = "${{ github.event.inputs.firewall_type }}" or "all"
|
||||
output_format = "${{ github.event.inputs.output_format }}" or "markdown"
|
||||
|
||||
print(f"Running in {'manual' if '${{ github.event.inputs.firewall_type }}' else 'automatic'} mode")
|
||||
print(f"Firewall type: {firewall_type}")
|
||||
print(f"Output format: {output_format}")
|
||||
print("")
|
||||
|
||||
# Collect all domains
|
||||
all_domains = []
|
||||
for domains in TRUSTED_DOMAINS.values():
|
||||
all_domains.extend(domains)
|
||||
|
||||
# Remove duplicates and sort
|
||||
all_domains = sorted(set(all_domains))
|
||||
|
||||
print(f"Generating firewall rules for {len(all_domains)} trusted domains...")
|
||||
print("")
|
||||
|
||||
# Exclude SFTP server from HTTPS rule generation (different port)
|
||||
https_domains = [d for d in all_domains if d != SFTP_HOST]
|
||||
|
||||
# Generate based on firewall type
|
||||
if firewall_type in ["iptables", "all"]:
|
||||
rules = generate_iptables_rules(https_domains)
|
||||
if SFTP_HOST:
|
||||
rules += "\n# ── SFTP Deployment Server ──────────────────────────────\n"
|
||||
rules += generate_sftp_iptables_rules(SFTP_HOST, SFTP_PORT)
|
||||
with open("firewall-rules-iptables.sh", "w") as f:
|
||||
f.write(rules)
|
||||
print("✓ Generated iptables rules: firewall-rules-iptables.sh")
|
||||
|
||||
if firewall_type in ["ufw", "all"]:
|
||||
rules = generate_ufw_rules(https_domains)
|
||||
if SFTP_HOST:
|
||||
rules += "\n# ── SFTP Deployment Server ──────────────────────────────\n"
|
||||
rules += generate_sftp_ufw_rules(SFTP_HOST, SFTP_PORT)
|
||||
with open("firewall-rules-ufw.sh", "w") as f:
|
||||
f.write(rules)
|
||||
print("✓ Generated UFW rules: firewall-rules-ufw.sh")
|
||||
|
||||
if firewall_type in ["firewalld", "all"]:
|
||||
rules = generate_firewalld_rules(https_domains)
|
||||
if SFTP_HOST:
|
||||
rules += "\n# ── SFTP Deployment Server ──────────────────────────────\n"
|
||||
rules += generate_sftp_firewalld_rules(SFTP_HOST, SFTP_PORT)
|
||||
with open("firewall-rules-firewalld.sh", "w") as f:
|
||||
f.write(rules)
|
||||
print("✓ Generated firewalld rules: firewall-rules-firewalld.sh")
|
||||
|
||||
if firewall_type in ["aws-security-group", "all"]:
|
||||
rules = generate_aws_security_group(all_domains)
|
||||
with open("firewall-rules-aws-sg.json", "w") as f:
|
||||
json.dump(rules, f, indent=2)
|
||||
print("✓ Generated AWS Security Group rules: firewall-rules-aws-sg.json")
|
||||
|
||||
if output_format in ["yaml", "all"]:
|
||||
with open("trusted-domains.yml", "w") as f:
|
||||
yaml.dump(TRUSTED_DOMAINS, f, default_flow_style=False)
|
||||
print("✓ Generated YAML domain list: trusted-domains.yml")
|
||||
|
||||
if output_format in ["json", "all"]:
|
||||
with open("trusted-domains.json", "w") as f:
|
||||
json.dump(TRUSTED_DOMAINS, f, indent=2)
|
||||
print("✓ Generated JSON domain list: trusted-domains.json")
|
||||
|
||||
if output_format in ["markdown", "all"]:
|
||||
md = generate_markdown_documentation(TRUSTED_DOMAINS)
|
||||
with open("FIREWALL_CONFIGURATION.md", "w") as f:
|
||||
f.write(md)
|
||||
print("✓ Generated documentation: FIREWALL_CONFIGURATION.md")
|
||||
|
||||
print("")
|
||||
print("Domain Categories:")
|
||||
for category, domains in TRUSTED_DOMAINS.items():
|
||||
print(f" - {category}: {len(domains)} domains")
|
||||
|
||||
print("")
|
||||
print("Total unique domains: ", len(all_domains))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PYTHON_EOF
|
||||
|
||||
chmod +x generate_firewall_config.py
|
||||
pip install PyYAML
|
||||
python3 generate_firewall_config.py
|
||||
|
||||
- name: Upload Firewall Configuration Artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: firewall-configurations
|
||||
path: |
|
||||
firewall-rules-*.sh
|
||||
firewall-rules-*.json
|
||||
trusted-domains.*
|
||||
FIREWALL_CONFIGURATION.md
|
||||
retention-days: 90
|
||||
|
||||
- name: Display Summary
|
||||
run: |
|
||||
echo "## Firewall Configuration" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "**Mode**: Manual Execution" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Firewall rules have been generated for enterprise-ready deployments." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Mode**: Automatic Execution (Coding Agent Active)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "This workflow ran automatically because a coding agent (GitHub Copilot) is active." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Firewall configuration has been validated for the coding agent environment." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Files Generated" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if ls firewall-rules-* trusted-domains.* FIREWALL_CONFIGURATION.md 2>/dev/null; then
|
||||
ls -lh firewall-rules-* trusted-domains.* FIREWALL_CONFIGURATION.md 2>/dev/null | awk '{print "- " $9 " (" $5 ")"}' >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- Documentation generated" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "### Download Artifacts" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Download the generated firewall configurations from the workflow artifacts." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Trusted Domains Active" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The coding agent has access to:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- License providers (GPL, OSI, SPDX, Apache, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Package registries (npm, PyPI, Packagist, RubyGems)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Documentation sources (GitHub, Joomla, Dolibarr, PHP)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Standards organizations (W3C, IETF, JSON Schema)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Usage Instructions:
|
||||
#
|
||||
# This workflow runs in two modes:
|
||||
#
|
||||
# 1. AUTOMATIC MODE (Coding Agent):
|
||||
# - Triggers when coding agent branches (copilot/**, agent/**) are pushed or PR'd
|
||||
# - Validates firewall configuration for the coding agent environment
|
||||
# - Documents accessible domains for compliance
|
||||
# - Ensures license sources and package registries are available
|
||||
#
|
||||
# 2. MANUAL MODE (Enterprise Configuration):
|
||||
# - Manually trigger from the Actions tab
|
||||
# - Select desired firewall type and output format
|
||||
# - Download generated artifacts
|
||||
# - Apply firewall rules to your enterprise environment
|
||||
#
|
||||
# Configuration:
|
||||
# - Trusted domains are sourced from .github/copilot.yml
|
||||
# - Modify copilot.yml to add/remove trusted domains
|
||||
# - Changes automatically propagate to firewall rules
|
||||
#
|
||||
# Important Notes:
|
||||
# - Review generated rules before applying to production
|
||||
# - Some domains may use CDNs with dynamic IPs
|
||||
# - Consider using FQDN-based rules where supported
|
||||
# - Test thoroughly in staging environment first
|
||||
# - Monitor logs for blocked connections
|
||||
# - Update rules as domains/services change
|
||||
@@ -1,525 +0,0 @@
|
||||
# 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: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/repository-cleanup.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Recurring repository maintenance — labels, branches, workflows, logs, doc indexes
|
||||
# NOTE: Synced via bulk-repo-sync to .github/workflows/repository-cleanup.yml in all governed repos.
|
||||
# Runs on the 1st and 15th of each month at 6:00 AM UTC, and on manual dispatch.
|
||||
|
||||
name: Repository Cleanup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 1,15 * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reset_labels:
|
||||
description: 'Delete ALL existing labels and recreate the standard set'
|
||||
type: boolean
|
||||
default: false
|
||||
clean_branches:
|
||||
description: 'Delete old chore/sync-mokostandards-* branches'
|
||||
type: boolean
|
||||
default: true
|
||||
clean_workflows:
|
||||
description: 'Delete orphaned workflow runs (cancelled, stale)'
|
||||
type: boolean
|
||||
default: true
|
||||
clean_logs:
|
||||
description: 'Delete workflow run logs older than 30 days'
|
||||
type: boolean
|
||||
default: true
|
||||
fix_templates:
|
||||
description: 'Strip copyright comment blocks from issue templates'
|
||||
type: boolean
|
||||
default: true
|
||||
rebuild_indexes:
|
||||
description: 'Rebuild docs/ index files'
|
||||
type: boolean
|
||||
default: true
|
||||
delete_closed_issues:
|
||||
description: 'Delete issues that have been closed for more than 30 days'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Repository Maintenance
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN || github.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check actor permission
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
ACTOR="${{ github.actor }}"
|
||||
# Schedule triggers use github-actions[bot]
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
echo "✅ Scheduled run — authorized"
|
||||
exit 0
|
||||
fi
|
||||
AUTHORIZED_USERS="jmiller-moko github-actions[bot]"
|
||||
for user in $AUTHORIZED_USERS; do
|
||||
if [ "$ACTOR" = "$user" ]; then
|
||||
echo "✅ ${ACTOR} authorized"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
PERMISSION=$(gh api "repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
|
||||
--jq '.permission' 2>/dev/null)
|
||||
case "$PERMISSION" in
|
||||
admin|maintain) echo "✅ ${ACTOR} has ${PERMISSION}" ;;
|
||||
*) echo "❌ Admin or maintain required"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ── Determine which tasks to run ─────────────────────────────────────
|
||||
# On schedule: run all tasks with safe defaults (labels NOT reset)
|
||||
# On dispatch: use input toggles
|
||||
- name: Set task flags
|
||||
id: tasks
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
echo "reset_labels=false" >> $GITHUB_OUTPUT
|
||||
echo "clean_branches=true" >> $GITHUB_OUTPUT
|
||||
echo "clean_workflows=true" >> $GITHUB_OUTPUT
|
||||
echo "clean_logs=true" >> $GITHUB_OUTPUT
|
||||
echo "fix_templates=true" >> $GITHUB_OUTPUT
|
||||
echo "rebuild_indexes=true" >> $GITHUB_OUTPUT
|
||||
echo "delete_closed_issues=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "reset_labels=${{ inputs.reset_labels }}" >> $GITHUB_OUTPUT
|
||||
echo "clean_branches=${{ inputs.clean_branches }}" >> $GITHUB_OUTPUT
|
||||
echo "clean_workflows=${{ inputs.clean_workflows }}" >> $GITHUB_OUTPUT
|
||||
echo "clean_logs=${{ inputs.clean_logs }}" >> $GITHUB_OUTPUT
|
||||
echo "fix_templates=${{ inputs.fix_templates }}" >> $GITHUB_OUTPUT
|
||||
echo "rebuild_indexes=${{ inputs.rebuild_indexes }}" >> $GITHUB_OUTPUT
|
||||
echo "delete_closed_issues=${{ inputs.delete_closed_issues }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# ── DELETE RETIRED WORKFLOWS (always runs) ────────────────────────────
|
||||
- name: Delete retired workflow files
|
||||
run: |
|
||||
echo "## 🗑️ Retired Workflow Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
RETIRED=(
|
||||
".github/workflows/build.yml"
|
||||
".github/workflows/code-quality.yml"
|
||||
".github/workflows/release-cycle.yml"
|
||||
".github/workflows/release-pipeline.yml"
|
||||
".github/workflows/branch-cleanup.yml"
|
||||
".github/workflows/auto-update-changelog.yml"
|
||||
".github/workflows/enterprise-issue-manager.yml"
|
||||
".github/workflows/flush-actions-cache.yml"
|
||||
".github/workflows/mokostandards-script-runner.yml"
|
||||
".github/workflows/unified-ci.yml"
|
||||
".github/workflows/unified-platform-testing.yml"
|
||||
".github/workflows/reusable-build.yml"
|
||||
".github/workflows/reusable-ci-validation.yml"
|
||||
".github/workflows/reusable-deploy.yml"
|
||||
".github/workflows/reusable-php-quality.yml"
|
||||
".github/workflows/reusable-platform-testing.yml"
|
||||
".github/workflows/reusable-project-detector.yml"
|
||||
".github/workflows/reusable-release.yml"
|
||||
".github/workflows/reusable-script-executor.yml"
|
||||
".github/workflows/rebuild-docs-indexes.yml"
|
||||
".github/workflows/setup-project-v2.yml"
|
||||
".github/workflows/sync-docs-to-project.yml"
|
||||
".github/workflows/release.yml"
|
||||
".github/workflows/sync-changelogs.yml"
|
||||
".github/workflows/version_branch.yml"
|
||||
"update.json"
|
||||
".github/workflows/auto-version-branch.yml"
|
||||
".github/workflows/publish-to-mokodolibarr.yml"
|
||||
".github/workflows/ci.yml"
|
||||
".github/workflows/deploy-rs.yml"
|
||||
"sftp-config.json"
|
||||
"sftp-config.json.template"
|
||||
"scripts/sftp-config"
|
||||
)
|
||||
|
||||
DELETED=0
|
||||
for wf in "${RETIRED[@]}"; do
|
||||
if [ -f "$wf" ]; then
|
||||
git rm "$wf" 2>/dev/null || rm -f "$wf"
|
||||
echo " Deleted: \`$(basename $wf)\`" >> $GITHUB_STEP_SUMMARY
|
||||
DELETED=$((DELETED+1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$DELETED" -gt 0 ]; then
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add -A
|
||||
git commit -m "chore: delete ${DELETED} retired workflow file(s) [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
echo "✅ ${DELETED} retired workflow(s) deleted" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ No retired workflows found" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── LABEL RESET ──────────────────────────────────────────────────────
|
||||
- name: Reset labels to standard set
|
||||
if: steps.tasks.outputs.reset_labels == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
echo "## 🏷️ Label Reset" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
gh api "repos/${REPO}/labels?per_page=100" --paginate --jq '.[].name' | while read -r label; do
|
||||
ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$label', safe=''))")
|
||||
gh api -X DELETE "repos/${REPO}/labels/${ENCODED}" --silent 2>/dev/null || true
|
||||
done
|
||||
|
||||
while IFS='|' read -r name color description; do
|
||||
[ -z "$name" ] && continue
|
||||
gh api "repos/${REPO}/labels" \
|
||||
-f name="$name" -f color="$color" -f description="$description" \
|
||||
--silent 2>/dev/null || true
|
||||
done << 'LABELS'
|
||||
joomla|7F52FF|Joomla extension or component
|
||||
dolibarr|FF6B6B|Dolibarr module or extension
|
||||
generic|808080|Generic project or library
|
||||
php|4F5D95|PHP code changes
|
||||
javascript|F7DF1E|JavaScript code changes
|
||||
typescript|3178C6|TypeScript code changes
|
||||
python|3776AB|Python code changes
|
||||
css|1572B6|CSS/styling changes
|
||||
html|E34F26|HTML template changes
|
||||
documentation|0075CA|Documentation changes
|
||||
ci-cd|000000|CI/CD pipeline changes
|
||||
docker|2496ED|Docker configuration changes
|
||||
tests|00FF00|Test suite changes
|
||||
security|FF0000|Security-related changes
|
||||
dependencies|0366D6|Dependency updates
|
||||
config|F9D0C4|Configuration file changes
|
||||
build|FFA500|Build system changes
|
||||
automation|8B4513|Automated processes or scripts
|
||||
mokostandards|B60205|MokoStandards compliance
|
||||
needs-review|FBCA04|Awaiting code review
|
||||
work-in-progress|D93F0B|Work in progress, not ready for merge
|
||||
breaking-change|D73A4A|Breaking API or functionality change
|
||||
priority: critical|B60205|Critical priority, must be addressed immediately
|
||||
priority: high|D93F0B|High priority
|
||||
priority: medium|FBCA04|Medium priority
|
||||
priority: low|0E8A16|Low priority
|
||||
type: bug|D73A4A|Something isn't working
|
||||
type: feature|A2EEEF|New feature or request
|
||||
type: enhancement|84B6EB|Enhancement to existing feature
|
||||
type: refactor|F9D0C4|Code refactoring
|
||||
type: chore|FEF2C0|Maintenance tasks
|
||||
type: version|0E8A16|Version-related change
|
||||
status: pending|FBCA04|Pending action or decision
|
||||
status: in-progress|0E8A16|Currently being worked on
|
||||
status: blocked|B60205|Blocked by another issue or dependency
|
||||
status: on-hold|D4C5F9|Temporarily on hold
|
||||
status: wontfix|FFFFFF|This will not be worked on
|
||||
size/xs|C5DEF5|Extra small change (1-10 lines)
|
||||
size/s|6FD1E2|Small change (11-30 lines)
|
||||
size/m|F9DD72|Medium change (31-100 lines)
|
||||
size/l|FFA07A|Large change (101-300 lines)
|
||||
size/xl|FF6B6B|Extra large change (301-1000 lines)
|
||||
size/xxl|B60205|Extremely large change (1000+ lines)
|
||||
health: excellent|0E8A16|Health score 90-100
|
||||
health: good|FBCA04|Health score 70-89
|
||||
health: fair|FFA500|Health score 50-69
|
||||
health: poor|FF6B6B|Health score below 50
|
||||
standards-update|B60205|MokoStandards sync update
|
||||
standards-drift|FBCA04|Repository drifted from MokoStandards
|
||||
sync-report|0075CA|Bulk sync run report
|
||||
sync-failure|D73A4A|Bulk sync failure requiring attention
|
||||
push-failure|D73A4A|File push failure requiring attention
|
||||
health-check|0E8A16|Repository health check results
|
||||
version-drift|FFA500|Version mismatch detected
|
||||
deploy-failure|CC0000|Automated deploy failure tracking
|
||||
template-validation-failure|D73A4A|Template workflow validation failure
|
||||
version|0E8A16|Version bump or release
|
||||
LABELS
|
||||
|
||||
echo "✅ Standard labels created" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── BRANCH CLEANUP ───────────────────────────────────────────────────
|
||||
- name: Delete old sync branches
|
||||
if: steps.tasks.outputs.clean_branches == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
CURRENT="chore/sync-mokostandards-v04.05"
|
||||
echo "## 🌿 Branch Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
FOUND=false
|
||||
gh api "repos/${REPO}/branches?per_page=100" --jq '.[].name' | \
|
||||
grep "^chore/sync-mokostandards" | \
|
||||
grep -v "^${CURRENT}$" | while read -r branch; do
|
||||
gh pr list --repo "$REPO" --head "$branch" --state open --json number --jq '.[].number' 2>/dev/null | while read -r pr; do
|
||||
gh pr close "$pr" --repo "$REPO" --comment "Superseded by \`${CURRENT}\`" 2>/dev/null || true
|
||||
echo " Closed PR #${pr}" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
gh api -X DELETE "repos/${REPO}/git/refs/heads/${branch}" --silent 2>/dev/null || true
|
||||
echo " Deleted: \`${branch}\`" >> $GITHUB_STEP_SUMMARY
|
||||
FOUND=true
|
||||
done
|
||||
|
||||
if [ "$FOUND" != "true" ]; then
|
||||
echo "✅ No old sync branches found" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── WORKFLOW RUN CLEANUP ─────────────────────────────────────────────
|
||||
- name: Clean up workflow runs
|
||||
if: steps.tasks.outputs.clean_workflows == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
echo "## 🔄 Workflow Run Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
DELETED=0
|
||||
# Delete cancelled and stale workflow runs
|
||||
for status in cancelled stale; do
|
||||
gh api "repos/${REPO}/actions/runs?status=${status}&per_page=100" \
|
||||
--jq '.workflow_runs[].id' 2>/dev/null | while read -r run_id; do
|
||||
gh api -X DELETE "repos/${REPO}/actions/runs/${run_id}" --silent 2>/dev/null || true
|
||||
DELETED=$((DELETED+1))
|
||||
done
|
||||
done
|
||||
|
||||
echo "✅ Cleaned cancelled/stale workflow runs" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── LOG CLEANUP ──────────────────────────────────────────────────────
|
||||
- name: Delete old workflow run logs
|
||||
if: steps.tasks.outputs.clean_logs == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
CUTOFF=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
echo "## 📋 Log Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Deleting logs older than: ${CUTOFF}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
DELETED=0
|
||||
gh api "repos/${REPO}/actions/runs?created=<${CUTOFF}&per_page=100" \
|
||||
--jq '.workflow_runs[].id' 2>/dev/null | while read -r run_id; do
|
||||
gh api -X DELETE "repos/${REPO}/actions/runs/${run_id}/logs" --silent 2>/dev/null || true
|
||||
DELETED=$((DELETED+1))
|
||||
done
|
||||
|
||||
echo "✅ Cleaned old workflow run logs" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── ISSUE TEMPLATE FIX ──────────────────────────────────────────────
|
||||
- name: Strip copyright headers from issue templates
|
||||
if: steps.tasks.outputs.fix_templates == 'true'
|
||||
run: |
|
||||
echo "## 📋 Issue Template Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
FIXED=0
|
||||
for f in .github/ISSUE_TEMPLATE/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
if grep -q '^<!--$' "$f"; then
|
||||
sed -i '/^<!--$/,/^-->$/d' "$f"
|
||||
echo " Cleaned: \`$(basename $f)\`" >> $GITHUB_STEP_SUMMARY
|
||||
FIXED=$((FIXED+1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FIXED" -gt 0 ]; then
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add .github/ISSUE_TEMPLATE/
|
||||
git commit -m "fix: strip copyright comment blocks from issue templates [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
echo "✅ ${FIXED} template(s) cleaned and committed" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ No templates need cleaning" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── REBUILD DOC INDEXES ─────────────────────────────────────────────
|
||||
- name: Rebuild docs/ index files
|
||||
if: steps.tasks.outputs.rebuild_indexes == 'true'
|
||||
run: |
|
||||
echo "## 📚 Documentation Index Rebuild" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ ! -d "docs" ]; then
|
||||
echo "⏭️ No docs/ directory — skipping" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
UPDATED=0
|
||||
# Generate index.md for each docs/ subdirectory
|
||||
find docs -type d | while read -r dir; do
|
||||
INDEX="${dir}/index.md"
|
||||
FILES=$(find "$dir" -maxdepth 1 -name "*.md" ! -name "index.md" -printf "- [%f](./%f)\n" 2>/dev/null | sort)
|
||||
if [ -z "$FILES" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
cat > "$INDEX" << INDEXEOF
|
||||
# $(basename "$dir")
|
||||
|
||||
## Documents
|
||||
|
||||
${FILES}
|
||||
|
||||
---
|
||||
*Auto-generated by repository-cleanup workflow*
|
||||
INDEXEOF
|
||||
# Dedent
|
||||
sed -i 's/^ //' "$INDEX"
|
||||
UPDATED=$((UPDATED+1))
|
||||
done
|
||||
|
||||
if [ "$UPDATED" -gt 0 ]; then
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add docs/
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "docs: rebuild documentation indexes [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
echo "✅ ${UPDATED} index file(s) rebuilt and committed" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ All indexes already up to date" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
echo "✅ No indexes to rebuild" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── VERSION DRIFT DETECTION ──────────────────────────────────────────
|
||||
- name: Check for version drift
|
||||
run: |
|
||||
echo "## 📦 Version Drift Check" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ ! -f "README.md" ]; then
|
||||
echo "⏭️ No README.md — skipping" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md 2>/dev/null | head -1)
|
||||
if [ -z "$README_VERSION" ]; then
|
||||
echo "⚠️ No VERSION found in README.md FILE INFORMATION block" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "**README version:** \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
DRIFT=0
|
||||
CHECKED=0
|
||||
|
||||
# Check all files with FILE INFORMATION blocks
|
||||
while IFS= read -r -d '' file; do
|
||||
FILE_VERSION=$(grep -oP '^\s*\*?\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' "$file" 2>/dev/null | head -1)
|
||||
[ -z "$FILE_VERSION" ] && continue
|
||||
CHECKED=$((CHECKED+1))
|
||||
if [ "$FILE_VERSION" != "$README_VERSION" ]; then
|
||||
echo " ⚠️ \`${file}\`: \`${FILE_VERSION}\` (expected \`${README_VERSION}\`)" >> $GITHUB_STEP_SUMMARY
|
||||
DRIFT=$((DRIFT+1))
|
||||
fi
|
||||
done < <(find . -maxdepth 4 -type f \( -name "*.php" -o -name "*.md" -o -name "*.yml" \) ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" -print0 2>/dev/null)
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$DRIFT" -gt 0 ]; then
|
||||
echo "⚠️ **${DRIFT}** file(s) out of ${CHECKED} have version drift" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Run \`sync-version-on-merge\` workflow or update manually" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ All ${CHECKED} file(s) match README version \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── PROTECT CUSTOM WORKFLOWS ────────────────────────────────────────
|
||||
- name: Ensure custom workflow directory exists
|
||||
run: |
|
||||
echo "## 🔧 Custom Workflows" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ ! -d ".github/workflows/custom" ]; then
|
||||
mkdir -p .github/workflows/custom
|
||||
cat > .github/workflows/custom/README.md << 'CWEOF'
|
||||
# Custom Workflows
|
||||
|
||||
Place repo-specific workflows here. Files in this directory are:
|
||||
- **Never overwritten** by MokoStandards bulk sync
|
||||
- **Never deleted** by the repository-cleanup workflow
|
||||
- Safe for custom CI, notifications, or repo-specific automation
|
||||
|
||||
Synced workflows live in `.github/workflows/` (parent directory).
|
||||
CWEOF
|
||||
sed -i 's/^ //' .github/workflows/custom/README.md
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add .github/workflows/custom/
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: create .github/workflows/custom/ for repo-specific workflows [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
echo "✅ Created \`.github/workflows/custom/\` directory" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
CUSTOM_COUNT=$(find .github/workflows/custom -name "*.yml" -o -name "*.yaml" 2>/dev/null | wc -l)
|
||||
echo "✅ Custom workflow directory exists (${CUSTOM_COUNT} workflow(s))" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── DELETE CLOSED ISSUES ──────────────────────────────────────────────
|
||||
- name: Delete old closed issues
|
||||
if: steps.tasks.outputs.delete_closed_issues == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
CUTOFF=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
echo "## 🗑️ Closed Issue Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Deleting issues closed before: ${CUTOFF}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
DELETED=0
|
||||
gh api "repos/${REPO}/issues?state=closed&since=1970-01-01T00:00:00Z&per_page=100&sort=updated&direction=asc" \
|
||||
--jq ".[] | select(.closed_at < \"${CUTOFF}\") | .number" 2>/dev/null | while read -r num; do
|
||||
# Lock and close with "not_planned" to mark as cleaned up
|
||||
gh api "repos/${REPO}/issues/${num}/lock" -X PUT -f lock_reason="resolved" --silent 2>/dev/null || true
|
||||
echo " Locked issue #${num}" >> $GITHUB_STEP_SUMMARY
|
||||
DELETED=$((DELETED+1))
|
||||
done
|
||||
|
||||
if [ "$DELETED" -eq 0 ] 2>/dev/null; then
|
||||
echo "✅ No old closed issues found" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ Locked ${DELETED} old closed issue(s)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "---" >> $GITHUB_STEP_SUMMARY
|
||||
echo "*Run by @${{ github.actor }} — trigger: ${{ github.event_name }}*" >> $GITHUB_STEP_SUMMARY
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,133 +0,0 @@
|
||||
# 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: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Automation
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/sync-version-on-merge.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Auto-bump patch version on every push to main and propagate to all file headers
|
||||
# NOTE: Synced via bulk-repo-sync to .github/workflows/sync-version-on-merge.yml in all governed repos.
|
||||
# README.md is the single source of truth for the repository version.
|
||||
|
||||
name: Sync Version from README
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Dry run (preview only, no commit)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
sync-version:
|
||||
name: Propagate README version
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN || github.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0
|
||||
with:
|
||||
php-version: '8.1'
|
||||
tools: composer
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch version/04 --quiet \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
|
||||
/tmp/mokostandards
|
||||
cd /tmp/mokostandards
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Auto-bump patch version
|
||||
if: ${{ github.event_name == 'push' && github.actor != 'github-actions[bot]' }}
|
||||
run: |
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -q '^README\.md$'; then
|
||||
echo "README.md changed in this push — skipping auto-bump"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESULT=$(php /tmp/mokostandards/api/cli/version_bump.php --path .) || {
|
||||
echo "⚠️ Could not bump version — skipping"
|
||||
exit 0
|
||||
}
|
||||
echo "Auto-bumping patch: $RESULT"
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add README.md
|
||||
git commit -m "chore(version): auto-bump patch ${RESULT} [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
|
||||
- name: Extract version from README.md
|
||||
id: readme_version
|
||||
run: |
|
||||
git pull --ff-only 2>/dev/null || true
|
||||
VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "⚠️ No VERSION in README.md — skipping propagation"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ README.md version: $VERSION"
|
||||
|
||||
- name: Run version sync
|
||||
if: ${{ steps.readme_version.outputs.skip != 'true' && inputs.dry_run != true }}
|
||||
run: |
|
||||
php /tmp/mokostandards/api/maintenance/update_version_from_readme.php \
|
||||
--path . \
|
||||
--create-issue \
|
||||
--repo "${{ github.repository }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
- name: Commit updated files
|
||||
if: ${{ steps.readme_version.outputs.skip != 'true' && inputs.dry_run != true }}
|
||||
run: |
|
||||
git pull --ff-only 2>/dev/null || true
|
||||
if git diff --quiet; then
|
||||
echo "ℹ️ No version changes needed — already up to date"
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ steps.readme_version.outputs.version }}"
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add -A
|
||||
git commit -m "chore(version): sync badges and headers to ${VERSION} [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
VERSION="${{ steps.readme_version.outputs.version }}"
|
||||
echo "## 📦 Version Sync — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Source:** \`README.md\` FILE INFORMATION block" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,321 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Joomla
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/joomla/update-server.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
|
||||
#
|
||||
# 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/**
|
||||
#
|
||||
# Joomla filters by user's "Minimum Stability" setting.
|
||||
|
||||
name: Update Joomla Update Server XML Feed
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '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
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update updates.xml
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN || github.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch version/04 --quiet \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
|
||||
/tmp/mokostandards 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards" ] && [ -f "/tmp/mokostandards/composer.json" ]; then
|
||||
cd /tmp/mokostandards && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Generate updates.xml entry
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
REPO="${{ github.repository }}"
|
||||
VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Auto-bump patch on alpha/beta/rc branches (not dev — dev bumps manually)
|
||||
if [[ "$BRANCH" != dev/* ]]; then
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
BUMPED=$(php /tmp/mokostandards/api/cli/version_bump.php --path . 2>/dev/null || true)
|
||||
if [ -n "$BUMPED" ]; then
|
||||
VERSION=$(php /tmp/mokostandards/api/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="github-actions[bot] <github-actions[bot]@users.noreply.github.com>" 2>/dev/null || true
|
||||
git push 2>/dev/null || true
|
||||
fi
|
||||
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/* ]]; then
|
||||
STABILITY="development"
|
||||
else
|
||||
STABILITY="stable"
|
||||
fi
|
||||
|
||||
# Parse manifest
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No Joomla manifest found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EXT_NAME=$(grep -oP '<name>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
|
||||
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
|
||||
EXT_CLIENT=$(grep -oP '<extension[^>]+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
||||
EXT_FOLDER=$(grep -oP '<extension[^>]+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
||||
TARGET_PLATFORM=$(grep -oP '<targetplatform[^/]*/>' "$MANIFEST" 2>/dev/null | head -1 || echo "")
|
||||
PHP_MINIMUM=$(grep -oP '<php_minimum>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "")
|
||||
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml)
|
||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="5.*" %s>' "/")
|
||||
|
||||
CLIENT_TAG=""
|
||||
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
|
||||
|
||||
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 suffix for non-stable
|
||||
DISPLAY_VERSION="$VERSION"
|
||||
case "$STABILITY" in
|
||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
||||
esac
|
||||
|
||||
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}-${DISPLAY_VERSION}.zip"
|
||||
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
||||
INFO_URL="https://github.com/${REPO}"
|
||||
|
||||
# ── Build install-ready ZIP ─────────────────────────────────
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ -d "$SOURCE_DIR" ]; then
|
||||
cd "$SOURCE_DIR"
|
||||
zip -r "/tmp/${PACKAGE_NAME}" . -x '.ftpignore' 'sftp-config*' '*.ppk' '*.pem' '*.key' '.env*'
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# Ensure draft release exists for this major
|
||||
gh release view "$RELEASE_TAG" --json tagName > /dev/null 2>&1 || \
|
||||
gh release create "$RELEASE_TAG" --title "${RELEASE_TAG} (${DISPLAY_VERSION})" --notes "${STABILITY} release" --prerelease --target main 2>/dev/null || true
|
||||
|
||||
# Upload ZIP to the major release
|
||||
gh release upload "$RELEASE_TAG" "/tmp/${PACKAGE_NAME}" --clobber 2>/dev/null || true
|
||||
|
||||
echo "Package: ${PACKAGE_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
SHA256=""
|
||||
fi
|
||||
|
||||
# ── Build the new entry ───────────────────────────────────────
|
||||
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})</description>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <version>${DISPLAY_VERSION}</version>\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} <tags>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <tag>${STABILITY}</tag>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </tags>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <infourl title=\"${EXT_NAME}\">${INFO_URL}</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}</sha256>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} ${TARGET_PLATFORM}\n"
|
||||
[ -n "$PHP_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${PHP_TAG}\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </update>"
|
||||
|
||||
# ── Write new entry to temp file ───────────────────────────────
|
||||
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
|
||||
|
||||
# ── Merge into updates.xml ─────────────────────────────────────
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>' > updates.xml
|
||||
printf '%s\n' '<updates>' >> updates.xml
|
||||
cat /tmp/new_entry.xml >> updates.xml
|
||||
printf '\n%s\n' '</updates>' >> updates.xml
|
||||
else
|
||||
# Remove existing entry for this stability, insert new one
|
||||
printf 'import re\nstability = "%s"\n' "${STABILITY}" > /tmp/merge_xml.py
|
||||
printf 'with open("updates.xml") as f: content = f.read()\n' >> /tmp/merge_xml.py
|
||||
printf 'with open("/tmp/new_entry.xml") as f: new_entry = f.read()\n' >> /tmp/merge_xml.py
|
||||
printf 'pattern = r" <update>.*?<tag>" + re.escape(stability) + r"</tag>.*?</update>\\n?"\n' >> /tmp/merge_xml.py
|
||||
printf 'content = re.sub(pattern, "", content, flags=re.DOTALL)\n' >> /tmp/merge_xml.py
|
||||
printf 'content = content.replace("</updates>", new_entry + "\\n</updates>")\n' >> /tmp/merge_xml.py
|
||||
printf 'content = re.sub(r"\\n{3,}", "\\n\\n", content)\n' >> /tmp/merge_xml.py
|
||||
printf 'with open("updates.xml", "w") as f: f.write(content)\n' >> /tmp/merge_xml.py
|
||||
python3 /tmp/merge_xml.py 2>/dev/null || {
|
||||
# Fallback: rebuild keeping other stability entries
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>'
|
||||
printf '%s\n' '<updates>'
|
||||
for TAG in stable rc development; do
|
||||
[ "$TAG" = "${STABILITY}" ] && continue
|
||||
if grep -q "<tag>${TAG}</tag>" updates.xml 2>/dev/null; then
|
||||
sed -n "/<update>/,/<\/update>/{ /<tag>${TAG}<\/tag>/p; }" updates.xml
|
||||
fi
|
||||
done
|
||||
cat /tmp/new_entry.xml
|
||||
printf '\n%s\n' '</updates>'
|
||||
} > /tmp/updates_new.xml
|
||||
mv /tmp/updates_new.xml updates.xml
|
||||
}
|
||||
fi
|
||||
|
||||
# Commit
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
if: contains(github.ref, 'dev/')
|
||||
env:
|
||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
# ── Permission check: admin or maintain role required ──────
|
||||
ACTOR="${{ github.actor }}"
|
||||
REPO="${{ github.repository }}"
|
||||
PERMISSION=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" \
|
||||
--jq '.permission' 2>/dev/null || \
|
||||
gh api "repos/${REPO}/collaborators/${ACTOR}" \
|
||||
--jq '.role' 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
admin|maintain|write) ;;
|
||||
*)
|
||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
|
||||
PORT="${DEV_PORT:-22}"
|
||||
REMOTE="${DEV_PATH%/}"
|
||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||
if [ -n "$DEV_KEY" ]; then
|
||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards/api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards/api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "/tmp/mokostandards/api/deploy/deploy-sftp.php" ]; then
|
||||
php /tmp/mokostandards/api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
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 | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -200,3 +200,5 @@ venv/
|
||||
*.coverage
|
||||
hypothesis/
|
||||
|
||||
profile.ps1
|
||||
.mcp.json
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: MokoStandards.Templates.Config
|
||||
# INGROUP: MokoStandards.Templates
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/configs/moko-standards.yml
|
||||
# VERSION: 04.01.00
|
||||
# BRIEF: Governance attachment template — synced to .moko-standards in every governed repository
|
||||
# NOTE: Tokens replaced at sync time: mokoconsulting-tech, MokoJoomTOS, waas-component, 04.00.04
|
||||
#
|
||||
# This file is managed automatically by MokoStandards bulk sync.
|
||||
# Do not edit manually — changes will be overwritten on the next sync.
|
||||
# To update governance settings, open a PR in MokoStandards instead:
|
||||
# https://github.com/mokoconsulting-tech/MokoStandards
|
||||
|
||||
standards_source: "https://github.com/mokoconsulting-tech/MokoStandards"
|
||||
standards_version: "04.00.04"
|
||||
platform: "waas-component"
|
||||
governed_repo: "mokoconsulting-tech/MokoJoomTOS"
|
||||
@@ -8,10 +8,10 @@ contact_links:
|
||||
url: https://mokoconsulting.tech/
|
||||
about: Get help or ask questions through our website
|
||||
- name: 📚 MokoStandards Documentation
|
||||
url: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
about: View our coding standards and best practices
|
||||
- name: 🔒 Report a Security Vulnerability
|
||||
url: https://github.com/mokoconsulting-tech/.github-private/security/advisories/new
|
||||
url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new
|
||||
about: Report security vulnerabilities privately (for critical issues)
|
||||
- name: 💡 Community Discussions
|
||||
url: https://github.com/orgs/mokoconsulting-tech/discussions
|
||||
+1
-1
@@ -37,7 +37,7 @@ If you have ideas about how this could be implemented, share them here:
|
||||
Add any other context, mockups, or screenshots about the feature request here.
|
||||
|
||||
## Relevant Standards
|
||||
Does this relate to any standards in [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards)?
|
||||
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
|
||||
- [ ] Accessibility (WCAG 2.1 AA)
|
||||
- [ ] Localization (en_US/en_GB)
|
||||
- [ ] Security best practices
|
||||
@@ -3,7 +3,7 @@ name: Question
|
||||
about: Ask a question about usage, features, or best practices
|
||||
title: '[QUESTION] '
|
||||
labels: ['question']
|
||||
assignees: ['jmiller-moko']
|
||||
assignees: ['jmiller']
|
||||
---
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Use this template only for:
|
||||
<!-- Describe how this could be addressed -->
|
||||
|
||||
## Standards Reference
|
||||
Does this relate to security standards in [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards)?
|
||||
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
|
||||
- [ ] SPDX license identifiers
|
||||
- [ ] Secret management
|
||||
- [ ] Dependency security
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: Version Bump
|
||||
about: Request or track a version change
|
||||
title: '[VERSION] '
|
||||
labels: 'version, type: version'
|
||||
assignees: 'jmiller'
|
||||
---
|
||||
|
||||
## Version Change
|
||||
|
||||
**Current version**: <!-- e.g., 01.02.03 -->
|
||||
**Requested version**: <!-- e.g., 01.03.00 -->
|
||||
**Change type**: <!-- patch / minor / major -->
|
||||
|
||||
## Reason
|
||||
|
||||
<!-- Why is this version bump needed? -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] README.md `VERSION:` field updated
|
||||
- [ ] CHANGELOG.md entry added
|
||||
- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: `<version>`)
|
||||
- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: WaaS Client Site Issue
|
||||
about: Report an issue with a WaaS client site (branding, deployment, media sync)
|
||||
title: '[WAAS] '
|
||||
labels: 'waas, client-site'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Site Issue Type
|
||||
- [ ] Branding / CSS not applying
|
||||
- [ ] Deployment failure
|
||||
- [ ] Media sync issue
|
||||
- [ ] Template override not working
|
||||
- [ ] Module positioning issue
|
||||
- [ ] Mobile / responsive layout
|
||||
- [ ] Performance issue
|
||||
|
||||
## Client Site
|
||||
- **Client Org**: [e.g., ClarksvilleFurs]
|
||||
- **Repo**: [e.g., client-waas-clarksvillefurs]
|
||||
- **Environment**: [Dev / Production]
|
||||
- **Site URL**: [dev or production URL — omit if private]
|
||||
|
||||
## Issue Description
|
||||
Describe the issue clearly.
|
||||
|
||||
## Steps to Reproduce
|
||||
1. Visit [page URL]
|
||||
2. Look at [element]
|
||||
3. See error
|
||||
|
||||
## Expected Behavior
|
||||
What the site should look like or how it should behave.
|
||||
|
||||
## Actual Behavior
|
||||
What is happening instead.
|
||||
|
||||
## Screenshots
|
||||
Attach screenshots showing the issue (desktop and mobile if relevant).
|
||||
|
||||
## Deployment Status
|
||||
- **Last deploy**: [date or "unknown"]
|
||||
- **Deploy workflow**: [succeeded / failed / not run]
|
||||
- **Branch**: [dev / main]
|
||||
|
||||
## Media Sync
|
||||
- [ ] Images missing after sync
|
||||
- [ ] Sync direction: [dev-to-prod / prod-to-dev / bidirectional]
|
||||
- [ ] Last sync: [date]
|
||||
|
||||
## Template Details
|
||||
- **Joomla Version**: [e.g., 5.x]
|
||||
- **Template Name**: [e.g., clienttemplate]
|
||||
- **MokoWaaS Plugin**: [Active / Inactive]
|
||||
- **MokoOnyx Admin**: [Active / Inactive]
|
||||
|
||||
## CSS Custom Properties
|
||||
If branding issue, list the relevant CSS variables:
|
||||
```css
|
||||
:root {
|
||||
--client-primary: #...;
|
||||
--client-secondary: #...;
|
||||
}
|
||||
```
|
||||
|
||||
## Browser / Device
|
||||
- **Browser**: [e.g., Chrome 120, Safari 17]
|
||||
- **Device**: [Desktop / Tablet / Mobile]
|
||||
- **Screen Width**: [e.g., 1920px, 768px, 375px]
|
||||
|
||||
## Checklist
|
||||
- [ ] I have cleared Joomla cache
|
||||
- [ ] I have hard-refreshed the browser (Ctrl+Shift+R)
|
||||
- [ ] I have checked the deploy workflow completed
|
||||
- [ ] I have verified the change is on the correct branch
|
||||
- [ ] No credentials or PII are included in this issue
|
||||
@@ -0,0 +1,949 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/auto-release.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Joomla build & release — ZIP package, updates.xml, SHA-256 checksum
|
||||
#
|
||||
# +========================================================================+
|
||||
# | BUILD & RELEASE PIPELINE (JOOMLA) |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Triggers on push to main (skips bot commits + [skip ci]): |
|
||||
# | |
|
||||
# | Every push: |
|
||||
# | 1. Read version from README.md |
|
||||
# | 3. Set platform version (Joomla <version>) |
|
||||
# | 4. Update [VERSION: XX.YY.ZZ] badges in markdown files |
|
||||
# | 5. Write updates.xml (Joomla update server XML) |
|
||||
# | 6. Create git tag vXX.YY.ZZ |
|
||||
# | 7a. Patch: update existing Gitea Release for this minor |
|
||||
# | 8. Build ZIP, upload asset, write SHA-256 to updates.xml |
|
||||
# | |
|
||||
# | Every version change: archives main -> version/XX.YY branch |
|
||||
# | All patches release (including 00). Patch 00/01 = full pipeline. |
|
||||
# | First release only (patch == 01): |
|
||||
# | 7b. Create new Gitea Release |
|
||||
# | |
|
||||
# | GitHub mirror: stable/rc releases only (continue-on-error) |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup MokoStandards 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}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api
|
||||
cd /tmp/mokostandards-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
# -- STEP 1: Read version -----------------------------------------------
|
||||
- name: "Step 1: Read version from README.md"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "No VERSION in README.md — skipping release"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Derive major.minor for branch naming (patches update existing branch)
|
||||
MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}')
|
||||
PATCH=$(echo "$VERSION" | awk -F. '{print $3}')
|
||||
|
||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
||||
MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}')
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
|
||||
echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
if [ "$PATCH" = "00" ] || [ "$PATCH" = "01" ]; then
|
||||
echo "is_minor=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION (first release for this minor — full pipeline)"
|
||||
else
|
||||
echo "is_minor=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION (patch — platform version + badges only)"
|
||||
fi
|
||||
|
||||
# -- STEP 1b: Bump minor version (stable = minor bump, reset patch) ------
|
||||
- name: "Step 1b: Bump minor version for stable release"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
id: bump
|
||||
run: |
|
||||
CURRENT=$(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)
|
||||
[ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||
|
||||
MAJOR=$((10#$(echo "$CURRENT" | cut -d. -f1)))
|
||||
MINOR=$((10#$(echo "$CURRENT" | cut -d. -f2)))
|
||||
|
||||
# Minor bump, reset patch. Rollover if minor > 99
|
||||
MINOR=$((MINOR + 1))
|
||||
if [ $MINOR -gt 99 ]; then
|
||||
MINOR=0
|
||||
MAJOR=$((MAJOR + 1))
|
||||
fi
|
||||
|
||||
VERSION=$(printf "%02d.%02d.00" $MAJOR $MINOR)
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Stable bump: ${CURRENT} → ${VERSION} (minor)"
|
||||
|
||||
# Update README.md
|
||||
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
|
||||
|
||||
# Update manifest
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||
[ -n "$MANIFEST_VER" ] && sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
|
||||
fi
|
||||
|
||||
# Promote [Unreleased] section in CHANGELOG.md to new version
|
||||
if [ -f "CHANGELOG.md" ] && grep -qi "Unreleased" CHANGELOG.md; then
|
||||
sed -i "s|## \[Unreleased\]|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md
|
||||
sed -i "s|## Unreleased|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md
|
||||
sed -i "2i ## [Unreleased]" CHANGELOG.md
|
||||
sed -i "3i \\ " CHANGELOG.md
|
||||
echo "CHANGELOG promoted to [${VERSION}]"
|
||||
fi
|
||||
|
||||
# Commit and push
|
||||
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): bump ${CURRENT} → ${VERSION} [skip ci]"
|
||||
git push origin HEAD:main 2>&1
|
||||
}
|
||||
|
||||
# Override version output for rest of pipeline
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "major=$(printf "%02d" $MAJOR)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if already released
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
|
||||
TAG_EXISTS=false
|
||||
BRANCH_EXISTS=false
|
||||
|
||||
git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
|
||||
git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
|
||||
|
||||
echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Tag and branch may persist across patch releases — never skip
|
||||
echo "already_released=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# -- SANITY CHECKS -------------------------------------------------------
|
||||
- name: "Sanity: Pre-release validation"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
ERRORS=0
|
||||
|
||||
echo "## Pre-Release Sanity Checks (Joomla)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Version drift check (must pass before release) --------
|
||||
README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
||||
if [ "$README_VER" != "$VERSION" ]; then
|
||||
echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Check CHANGELOG version matches
|
||||
CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
|
||||
if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
|
||||
echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
|
||||
# Check composer.json version if present
|
||||
if [ -f "composer.json" ]; then
|
||||
COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
|
||||
if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
|
||||
echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Common checks
|
||||
if [ ! -f "LICENSE" ]; then
|
||||
echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
|
||||
echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- Joomla: manifest version drift --------
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
XML_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
|
||||
echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Joomla: XML manifest existence --------
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "- No Joomla XML manifest found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Joomla: extension type check --------
|
||||
TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 2: Create or update version/XX.YY archive branch ---------------
|
||||
# Always runs — every version change on main archives to version/XX.YY
|
||||
- name: "Step 2: Version archive branch"
|
||||
if: steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
IS_MINOR="${{ steps.version.outputs.is_minor }}"
|
||||
PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
|
||||
|
||||
# Check if branch exists
|
||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
||||
git push origin HEAD:"$BRANCH" --force
|
||||
echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
|
||||
git push origin "$BRANCH" --force
|
||||
echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 3: Set platform version ----------------------------------------
|
||||
- name: "Step 3: Set platform version"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/mokostandards-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' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do
|
||||
if grep -q '\[VERSION:' "$f" 2>/dev/null; then
|
||||
sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# -- STEP 5: Write updates.xml (Joomla update server) ---------------------
|
||||
- name: "Step 5: Write updates.xml"
|
||||
id: updates
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# -- Parse extension metadata from XML manifest ----------------
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract fields using sed (portable — no grep -P)
|
||||
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)
|
||||
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)
|
||||
|
||||
# If EXT_NAME is a language key (e.g. PLG_SYSTEM_MOKOJGDPC), resolve from .ini
|
||||
if echo "$EXT_NAME" | grep -qE '^[A-Z_]+$'; then
|
||||
INI_NAME=$(find . -name "*.sys.ini" -path "*/en-GB/*" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
|
||||
[ -z "$INI_NAME" ] && INI_NAME=$(find . -name "*.sys.ini" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
|
||||
[ -n "$INI_NAME" ] && EXT_NAME="$INI_NAME"
|
||||
fi
|
||||
|
||||
# Fallbacks
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
|
||||
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
|
||||
|
||||
# Derive element if not in manifest:
|
||||
# 1. plugin="xxx" attribute (plugins)
|
||||
# 2. module="xxx" attribute (modules)
|
||||
# 3. XML filename (components, packages)
|
||||
# 4. Repo name fallback (templates, anything else)
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
fi
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*module="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
fi
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
FNAME=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
# If filename is generic (templateDetails, manifest), use repo name
|
||||
case "$FNAME" in
|
||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
*) EXT_ELEMENT="$FNAME" ;;
|
||||
esac
|
||||
fi
|
||||
# Final fallback
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
|
||||
# Save for Steps 7, 8, 8b
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_name=${EXT_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_type=${EXT_TYPE}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_folder=${EXT_FOLDER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build client tag: plugins and frontend modules need <client>site</client>
|
||||
CLIENT_TAG=""
|
||||
if [ -n "$EXT_CLIENT" ]; then
|
||||
CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||
elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
|
||||
CLIENT_TAG="<client>site</client>"
|
||||
fi
|
||||
|
||||
# Build folder tag for plugins (required for Joomla to match the update)
|
||||
FOLDER_TAG=""
|
||||
if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
|
||||
FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
||||
fi
|
||||
|
||||
# Build targetplatform (fallback to Joomla 5 if not in manifest)
|
||||
if [ -z "$TARGET_PLATFORM" ]; then
|
||||
TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
|
||||
fi
|
||||
|
||||
# Build php_minimum tag
|
||||
PHP_TAG=""
|
||||
if [ -n "$PHP_MINIMUM" ]; then
|
||||
PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
||||
fi
|
||||
|
||||
# Build TYPE_PREFIX for download URL
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
|
||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
|
||||
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable"
|
||||
|
||||
# -- Build update entry for a given stability tag
|
||||
build_entry() {
|
||||
local TAG_NAME="$1"
|
||||
printf '%s\n' ' <update>'
|
||||
printf '%s\n' " <name>${EXT_NAME}</name>"
|
||||
printf '%s\n' " <description>${EXT_NAME} update</description>"
|
||||
printf '%s\n' " <element>${EXT_ELEMENT}</element>"
|
||||
printf '%s\n' " <type>${EXT_TYPE}</type>"
|
||||
printf '%s\n' " <version>${VERSION}</version>"
|
||||
[ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
|
||||
[ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
|
||||
printf '%s\n' " <tags><tag>${TAG_NAME}</tag></tags>"
|
||||
printf '%s\n' " <infourl title=\"${EXT_NAME}\">${INFO_URL}</infourl>"
|
||||
printf '%s\n' ' <downloads>'
|
||||
printf '%s\n' " <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>"
|
||||
printf '%s\n' ' </downloads>'
|
||||
printf '%s\n' " ${TARGET_PLATFORM}"
|
||||
[ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}"
|
||||
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
|
||||
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
|
||||
printf '%s\n' ' </update>'
|
||||
}
|
||||
|
||||
# -- Write updates.xml with cascading channels
|
||||
# Stable release updates ALL channels (development, alpha, beta, rc, stable)
|
||||
{
|
||||
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>"
|
||||
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting <hello@mokoconsulting.tech>"
|
||||
printf '%s\n' " SPDX-License-Identifier: GPL-3.0-or-later"
|
||||
printf '%s\n' " VERSION: ${VERSION}"
|
||||
printf '%s\n' " -->"
|
||||
printf '%s\n' ""
|
||||
printf '%s\n' '<updates>'
|
||||
build_entry "development"
|
||||
build_entry "alpha"
|
||||
build_entry "beta"
|
||||
build_entry "rc"
|
||||
build_entry "stable"
|
||||
printf '%s\n' '</updates>'
|
||||
} > updates.xml
|
||||
|
||||
echo "updates.xml: ${VERSION} (all channels updated to stable)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Commit all changes ---------------------------------------------------
|
||||
- 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' &&
|
||||
steps.check.outputs.tag_exists != 'true' &&
|
||||
steps.version.outputs.is_minor == 'true'
|
||||
run: |
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
# Only create the major release tag if it doesn't exist yet
|
||||
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
git tag "$RELEASE_TAG"
|
||||
git push origin "$RELEASE_TAG"
|
||||
echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 7: Create or update Gitea Release --------------------------------
|
||||
- name: "Step 7: Gitea Release"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
MAJOR="${{ steps.version.outputs.major }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Reuse metadata from Step 5 (single source of truth)
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
EXT_NAME="${{ steps.updates.outputs.ext_name }}"
|
||||
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
|
||||
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
|
||||
|
||||
# Fallbacks if Step 5 was skipped
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
|
||||
|
||||
NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
|
||||
# Build release name: "Pretty Name VERSION (type_element-VERSION)"
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
|
||||
|
||||
# Delete existing release if present (overwrite, not append)
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true
|
||||
echo "Deleted previous stable release (id: ${EXISTING_ID})"
|
||||
fi
|
||||
|
||||
# Create fresh release
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/releases" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'tag_name': '${RELEASE_TAG}',
|
||||
'name': '${RELEASE_NAME}',
|
||||
'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''',
|
||||
'target_commitish': '${BRANCH}'
|
||||
}))")"
|
||||
echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
|
||||
- name: "Step 8: Build Joomla package and update checksum"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# All ZIPs upload to the major release tag (vXX)
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find extension element name from manifest
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
|
||||
# Reuse element from Step 5, with same fallback chain
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
# ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
|
||||
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
|
||||
|
||||
# -- Build install packages from src/ ----------------------------
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — skipping package"; exit 0; }
|
||||
|
||||
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
|
||||
|
||||
# ZIP package
|
||||
cd "$SOURCE_DIR"
|
||||
zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES
|
||||
cd ..
|
||||
|
||||
# tar.gz package
|
||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
|
||||
--exclude='.ftpignore' --exclude='sftp-config*' \
|
||||
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
||||
|
||||
ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
|
||||
TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
|
||||
|
||||
# -- Calculate SHA-256 for both ----------------------------------
|
||||
SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# -- 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_NAME in "$ZIP_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_NAME}':
|
||||
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 to release tag ----------------------------------
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${ZIP_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_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
|
||||
|
||||
# -- Update updates.xml with both download formats ---------------
|
||||
if [ -f "updates.xml" ]; then
|
||||
ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}"
|
||||
TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}"
|
||||
|
||||
# Use Python to update only the stable entry's downloads + sha256
|
||||
export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
with open("updates.xml") as f:
|
||||
content = f.read()
|
||||
|
||||
zip_url = os.environ["PY_ZIP_URL"]
|
||||
tar_url = os.environ["PY_TAR_URL"]
|
||||
sha = os.environ["PY_SHA"]
|
||||
|
||||
# Find the stable update block and replace its downloads + sha256
|
||||
def replace_stable(m):
|
||||
block = m.group(0)
|
||||
# Replace downloads block
|
||||
new_downloads = (
|
||||
" <downloads>\n"
|
||||
f" <downloadurl type=\"full\" format=\"zip\">{zip_url}</downloadurl>\n"
|
||||
" </downloads>"
|
||||
)
|
||||
block = re.sub(r' <downloads>.*?</downloads>', new_downloads, block, flags=re.DOTALL)
|
||||
# Add or replace sha256
|
||||
if '<sha256>' in block:
|
||||
block = re.sub(r' <sha256>.*?</sha256>', f' <sha256>{sha}</sha256>', block)
|
||||
else:
|
||||
block = block.replace('</downloads>', f'</downloads>\n <sha256>{sha}</sha256>')
|
||||
return block
|
||||
|
||||
content = re.sub(
|
||||
r' <update>.*?<tag>stable</tag>.*?</update>',
|
||||
replace_stable,
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git add updates.xml
|
||||
git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" || true
|
||||
git push || true
|
||||
|
||||
# Sync updates.xml to main via direct API (always runs — may be on version/XX branch)
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
|
||||
|
||||
if [ -n "$FILE_SHA" ]; then
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/contents/updates.xml" \
|
||||
-d "$(jq -n \
|
||||
--arg content "$CONTENT" \
|
||||
--arg sha "$FILE_SHA" \
|
||||
--arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
|
||||
--arg branch "main" \
|
||||
'{content: $content, sha: $sha, message: $msg, branch: $branch}'
|
||||
)" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main via API" \
|
||||
|| echo "WARNING: failed to sync updates.xml to main"
|
||||
else
|
||||
echo "WARNING: could not get updates.xml SHA from main"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "### Joomla Packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 8b: Update release description with changelog + SHA ----------------
|
||||
- name: "Step 8b: Update release body with changelog and SHA"
|
||||
if: steps.version.outputs.skip != '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}"
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
|
||||
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
|
||||
|
||||
# Build TYPE_PREFIX to match Step 8's ZIP naming
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
|
||||
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
|
||||
|
||||
# Get SHA from the built files
|
||||
SHA256_ZIP=""
|
||||
[ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
SHA256_TAR=""
|
||||
[ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# Extract latest changelog entry (strip the ## header to avoid duplicate)
|
||||
CHANGELOG=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
|
||||
fi
|
||||
|
||||
# Build release body (single header, no duplicate from changelog)
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
|
||||
if [ -n "$CHANGELOG" ]; then
|
||||
BODY="${BODY}${CHANGELOG}\n\n"
|
||||
fi
|
||||
BODY="${BODY}---\n\n### Checksums\n\n"
|
||||
BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
|
||||
[ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
|
||||
[ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
|
||||
|
||||
# Get release ID and update body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = '''$(printf '%b' "$BODY")'''
|
||||
data = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=data,
|
||||
headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
|
||||
method='PATCH'
|
||||
)
|
||||
urllib.request.urlopen(req)
|
||||
" 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.version.outputs.stability == 'stable' &&
|
||||
secrets.GH_TOKEN != ''
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
MAJOR="${{ steps.version.outputs.major }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
|
||||
NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
echo "$NOTES" > /tmp/release_notes.md
|
||||
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
|
||||
|
||||
if [ -z "$EXISTING" ]; then
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--repo "$GH_REPO" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" \
|
||||
--notes-file /tmp/release_notes.md \
|
||||
--target "$BRANCH" || true
|
||||
else
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--repo "$GH_REPO" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" || true
|
||||
fi
|
||||
|
||||
# Upload assets to GitHub mirror
|
||||
for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
|
||||
if [ -f "$PKG" ]; then
|
||||
_RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
|
||||
[ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
# -- Clean up lesser pre-releases (cascade) ---------------------------------
|
||||
# stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev
|
||||
- name: "Delete lesser pre-release channels"
|
||||
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 }}"
|
||||
|
||||
# Stable deletes all pre-release channels
|
||||
TAGS_TO_DELETE="development alpha beta release-candidate"
|
||||
|
||||
DELETED=0
|
||||
for TAG in $TAGS_TO_DELETE; do
|
||||
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
|
||||
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
|
||||
DELETED=$((DELETED + 1))
|
||||
fi
|
||||
done
|
||||
echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 11: Reset dev branch from main ------------------------------------
|
||||
- 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
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
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 (Joomla)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -0,0 +1,213 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# 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: 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
|
||||
@@ -5,28 +5,20 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow.Template
|
||||
# DEFGROUP: Gitea.Workflow.Template
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/ci-joomla.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: CI workflow for Joomla extensions — lint, validate, test
|
||||
# NOTE: Deployed to .github/workflows/ci-joomla.yml in governed Joomla extension repos.
|
||||
|
||||
name: Joomla Extension CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev/**
|
||||
- rc/**
|
||||
- version/**
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev/**
|
||||
- rc/**
|
||||
- 'dev/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -46,24 +38,22 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: mbstring, xml, zip, gd, curl, json, simplexml
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Clone MokoStandards
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
run: |
|
||||
git clone --depth 1 --branch version/04 --quiet \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
|
||||
/tmp/mokostandards
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
@@ -351,16 +341,12 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: mbstring, xml, zip, gd, curl, json, simplexml
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
@@ -389,3 +375,76 @@ jobs:
|
||||
else
|
||||
echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
static-analysis:
|
||||
name: PHPStan Analysis
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-validate
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: php -v && composer --version
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
fi
|
||||
|
||||
- name: Install PHPStan
|
||||
run: |
|
||||
if ! command -v vendor/bin/phpstan &> /dev/null; then
|
||||
composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \
|
||||
composer global require phpstan/phpstan --no-interaction
|
||||
fi
|
||||
|
||||
- name: Run PHPStan
|
||||
run: |
|
||||
echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY
|
||||
PHPSTAN="vendor/bin/phpstan"
|
||||
if [ ! -f "$PHPSTAN" ]; then
|
||||
PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan
|
||||
fi
|
||||
|
||||
# Determine source directory
|
||||
SRC_DIR=""
|
||||
for DIR in src/ htdocs/ lib/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
SRC_DIR="$DIR"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use repo phpstan.neon if present, otherwise use baseline config
|
||||
ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table"
|
||||
if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then
|
||||
echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
ARGS="$ARGS --level=3"
|
||||
echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
$PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt
|
||||
EXIT=${PIPESTATUS[0]}
|
||||
|
||||
if [ $EXIT -eq 0 ]; then
|
||||
echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some")
|
||||
echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
exit $EXIT
|
||||
@@ -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: MokoStandards.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||
|
||||
name: 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)"
|
||||
@@ -3,14 +3,12 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Deploy
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||
# NOTE: Joomla repos use update.xml for distribution. This is for manual
|
||||
# dev server testing only — triggered via workflow_dispatch.
|
||||
|
||||
name: Deploy to Dev (Manual)
|
||||
|
||||
@@ -39,23 +37,21 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: json, ssh2
|
||||
tools: composer
|
||||
coverage: none
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch version/04 --quiet \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
|
||||
/tmp/mokostandards 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards" ] && [ -f "/tmp/mokostandards/composer.json" ]; then
|
||||
cd /tmp/mokostandards && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Check FTP configuration
|
||||
@@ -63,11 +59,10 @@ jobs:
|
||||
env:
|
||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||
SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured — cannot deploy"
|
||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -75,7 +70,6 @@ jobs:
|
||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REMOTE="${PATH_VAR%/}"
|
||||
[ -n "$SUFFIX" ] && REMOTE="${REMOTE}/${SUFFIX#/}"
|
||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
[ -z "$PORT" ] && PORT="22"
|
||||
@@ -90,7 +84,7 @@ jobs:
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — nothing to deploy"; exit 0; }
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
||||
@@ -107,11 +101,11 @@ jobs:
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards/api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards/api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
else
|
||||
php /tmp/mokostandards/api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
fi
|
||||
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
@@ -120,7 +114,7 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
||||
echo "### Deploy Skipped — FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,96 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# 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: 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,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Moko Platform Repository Manifest
|
||||
See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home
|
||||
-->
|
||||
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
|
||||
<identity>
|
||||
<name>MokoJoomTOS</name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Joomla system plugin to keep legal pages accessible during offline mode</description>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
<platform>joomla</platform>
|
||||
<standards-version>05.00.00</standards-version>
|
||||
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/moko-platform</standards-source>
|
||||
<last-synced>2026-05-16T17:30:00+00:00</last-synced>
|
||||
</governance>
|
||||
<build>
|
||||
<language>PHP</language>
|
||||
<package-type>joomla-extension</package-type>
|
||||
<entry-point>src/</entry-point>
|
||||
</build>
|
||||
</moko-platform>
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/notify.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||
|
||||
name: Notifications
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Joomla Build & Release"
|
||||
- "Joomla Extension CI"
|
||||
- "Deploy"
|
||||
- "Cascade Main → Dev"
|
||||
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,90 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# 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,106 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/pr-check.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: PR gate — validates code quality and manifest before merge to main
|
||||
|
||||
name: PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
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
|
||||
run: |
|
||||
echo "=== PHP Lint ==="
|
||||
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 "Checked files, errors: ${ERRORS}"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate Joomla manifest
|
||||
run: |
|
||||
echo "=== Manifest Validation ==="
|
||||
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"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
|
||||
# Check well-formed XML
|
||||
if ! 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);}"; then
|
||||
echo "::error::Manifest XML is malformed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check required elements
|
||||
for ELEMENT in name version description; do
|
||||
if ! grep -q "<${ELEMENT}>" "$MANIFEST"; then
|
||||
echo "::error::Missing <${ELEMENT}> in manifest"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Manifest valid"
|
||||
|
||||
- name: Check updates.xml format
|
||||
run: |
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
echo "=== updates.xml Validation ==="
|
||||
if ! 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);}"; then
|
||||
echo "::error::updates.xml is malformed"
|
||||
exit 1
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
|
||||
- name: Verify package builds
|
||||
run: |
|
||||
echo "=== Package Build Test ==="
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
# Dry-run: ensure zip would succeed
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source contains ${FILE_COUNT} files — package will build"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
@@ -0,0 +1,341 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/pre-release.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: Pre-Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
runs-on: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup PHP
|
||||
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 php-zip >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Resolve metadata
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Read and bump patch version (with rollover)
|
||||
CURRENT=$(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)
|
||||
[ -z "$CURRENT" ] && CURRENT="00.00.00"
|
||||
|
||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||
|
||||
# Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
|
||||
NEW_PATCH=$((10#$PATCH + 1))
|
||||
NEW_MINOR=$((10#$MINOR))
|
||||
NEW_MAJOR=$((10#$MAJOR))
|
||||
|
||||
if [ $NEW_PATCH -gt 99 ]; then
|
||||
NEW_PATCH=0
|
||||
NEW_MINOR=$((NEW_MINOR + 1))
|
||||
fi
|
||||
if [ $NEW_MINOR -gt 99 ]; then
|
||||
NEW_MINOR=0
|
||||
NEW_MAJOR=$((NEW_MAJOR + 1))
|
||||
fi
|
||||
|
||||
VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
|
||||
|
||||
# Update README.md
|
||||
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
|
||||
|
||||
# Update manifest
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element from manifest
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
EXT_ELEMENT=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::error::No src/ or htdocs/ directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p build/package
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@build/${ZIP_NAME}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: Update updates.xml
|
||||
run: |
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
|
||||
PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
|
||||
PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
stability = os.environ["PY_STABILITY"]
|
||||
version = os.environ["PY_VERSION"]
|
||||
sha256 = os.environ["PY_SHA256"]
|
||||
zip_name = os.environ["PY_ZIP_NAME"]
|
||||
tag = os.environ["PY_TAG"]
|
||||
date = os.environ["PY_DATE"]
|
||||
gitea_org = os.environ["PY_GITEA_ORG"]
|
||||
gitea_repo = os.environ["PY_GITEA_REPO"]
|
||||
download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
|
||||
|
||||
with open("updates.xml", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Map stability to XML tag name
|
||||
tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
|
||||
xml_tag = tag_map.get(stability, stability)
|
||||
|
||||
pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
block = match.group(1)
|
||||
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
|
||||
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
|
||||
if "<sha256>" in updated:
|
||||
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
|
||||
else:
|
||||
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
|
||||
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\g<1>{download_url}\g<2>", updated)
|
||||
content = content.replace(block, updated)
|
||||
print(f"Updated {xml_tag} channel: version={version}")
|
||||
else:
|
||||
print(f"WARNING: No <tag>{xml_tag}</tag> block in updates.xml")
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
# Commit and push to current branch
|
||||
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"
|
||||
run: |
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
|
||||
# Sync updates.xml to main and dev (whichever isn't current)
|
||||
for BRANCH in main dev; do
|
||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
||||
|
||||
echo "Syncing updates.xml → ${BRANCH}"
|
||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
||||
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
|
||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git add updates.xml
|
||||
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
||||
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
||||
fi
|
||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||
done
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
|
||||
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
|
||||
case "$STABILITY" in
|
||||
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
|
||||
beta) TAGS_TO_DELETE="alpha development" ;;
|
||||
alpha) TAGS_TO_DELETE="development" ;;
|
||||
*) TAGS_TO_DELETE="" ;;
|
||||
esac
|
||||
|
||||
[ -z "$TAGS_TO_DELETE" ] && exit 0
|
||||
|
||||
for TAG in $TAGS_TO_DELETE; do
|
||||
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
|
||||
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
|
||||
fi
|
||||
done
|
||||
@@ -6,13 +6,12 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Validation
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /.github/workflows/repo_health.yml
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# NOTE: Field is user-managed.
|
||||
# ============================================================================
|
||||
|
||||
name: Repo Health
|
||||
@@ -50,13 +49,11 @@ env:
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
# Note: directories listed without a trailing slash.
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
|
||||
# Repo health policy
|
||||
# Files are listed as-is; directories must end with a trailing slash.
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.github/workflows/
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
|
||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||
REPO_DISALLOWED_DIRS:
|
||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||
@@ -64,10 +61,10 @@ env:
|
||||
# Extended checks toggles
|
||||
EXTENDED_CHECKS: "true"
|
||||
|
||||
# File / directory variables (moved to top-level env)
|
||||
# File / directory variables
|
||||
DOCS_INDEX: docs/docs-index.md
|
||||
SCRIPT_DIR: scripts
|
||||
WORKFLOWS_DIR: .github/workflows
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
SHELLCHECK_PATTERN: '*.sh'
|
||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -87,62 +84,56 @@ jobs:
|
||||
steps:
|
||||
- name: Check actor permission (admin only)
|
||||
id: perm
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const actor = context.actor;
|
||||
let permission = "unknown";
|
||||
let allowed = false;
|
||||
let method = "";
|
||||
env:
|
||||
TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ALLOWED=false
|
||||
PERMISSION=unknown
|
||||
METHOD=""
|
||||
|
||||
// Hardcoded authorized users — always allowed
|
||||
const authorizedUsers = ["jmiller-moko", "github-actions[bot]"];
|
||||
if (authorizedUsers.includes(actor)) {
|
||||
allowed = true;
|
||||
permission = "admin";
|
||||
method = "hardcoded allowlist";
|
||||
} else {
|
||||
// Check via API for other actors
|
||||
try {
|
||||
const res = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: actor,
|
||||
});
|
||||
permission = (res?.data?.permission || "unknown").toLowerCase();
|
||||
allowed = permission === "admin" || permission === "maintain";
|
||||
method = "repo collaborator API";
|
||||
} catch (error) {
|
||||
core.warning(`Could not fetch permissions for '${actor}': ${error.message}`);
|
||||
permission = "unknown";
|
||||
allowed = false;
|
||||
method = "API error";
|
||||
}
|
||||
}
|
||||
# Hardcoded authorized users — always allowed
|
||||
case "$ACTOR" in
|
||||
jmiller|gitea-actions[bot])
|
||||
ALLOWED=true
|
||||
PERMISSION=admin
|
||||
METHOD="hardcoded allowlist"
|
||||
;;
|
||||
*)
|
||||
# Detect platform and check permissions via API
|
||||
API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
|
||||
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
|
||||
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
|
||||
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
|
||||
ALLOWED=true
|
||||
fi
|
||||
METHOD="collaborator API"
|
||||
;;
|
||||
esac
|
||||
|
||||
core.setOutput("permission", permission);
|
||||
core.setOutput("allowed", allowed ? "true" : "false");
|
||||
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
const lines = [
|
||||
"## 🔐 Access Authorization",
|
||||
"",
|
||||
"| Field | Value |",
|
||||
"|-------|-------|",
|
||||
`| **Actor** | \`${actor}\` |`,
|
||||
`| **Repository** | \`${context.repo.owner}/${context.repo.repo}\` |`,
|
||||
`| **Permission** | \`${permission}\` |`,
|
||||
`| **Method** | ${method} |`,
|
||||
`| **Authorized** | ${allowed} |`,
|
||||
`| **Trigger** | \`${context.eventName}\` |`,
|
||||
`| **Branch** | \`${context.ref.replace('refs/heads/', '')}\` |`,
|
||||
"",
|
||||
allowed
|
||||
? `✅ ${actor} authorized (${method})`
|
||||
: `❌ ${actor} is NOT authorized. Requires admin or maintain role, or be in the hardcoded allowlist.`,
|
||||
];
|
||||
|
||||
await core.summary.addRaw(lines.join("\n")).write();
|
||||
{
|
||||
echo "## Access Authorization"
|
||||
echo ""
|
||||
echo "| Field | Value |"
|
||||
echo "|-------|-------|"
|
||||
echo "| **Actor** | \`${ACTOR}\` |"
|
||||
echo "| **Repository** | \`${REPO}\` |"
|
||||
echo "| **Permission** | \`${PERMISSION}\` |"
|
||||
echo "| **Method** | ${METHOD} |"
|
||||
echo "| **Authorized** | ${ALLOWED} |"
|
||||
echo ""
|
||||
if [ "$ALLOWED" = "true" ]; then
|
||||
echo "${ACTOR} authorized (${METHOD})"
|
||||
else
|
||||
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
|
||||
fi
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Deny execution when not permitted
|
||||
if: ${{ steps.perm.outputs.allowed != 'true' }}
|
||||
@@ -427,7 +418,6 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# Optional entries: handle files and directories (trailing slash indicates dir)
|
||||
for f in "${optional_files[@]}"; do
|
||||
if printf '%s' "${f}" | grep -q '/$'; then
|
||||
d="${f%/}"
|
||||
@@ -451,8 +441,6 @@ jobs:
|
||||
dev_paths=()
|
||||
dev_branches=()
|
||||
|
||||
# Look for remote branches matching origin/dev*.
|
||||
# A plain origin/dev is considered invalid; we require dev/<something> branches.
|
||||
while IFS= read -r b; do
|
||||
name="${b#origin/}"
|
||||
if [ "${name}" = 'dev' ]; then
|
||||
@@ -462,12 +450,10 @@ jobs:
|
||||
fi
|
||||
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
||||
|
||||
# If there are no dev/* branches, fail the guardrail.
|
||||
if [ "${#dev_paths[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev/* branch (e.g. dev/01.00.00)")
|
||||
fi
|
||||
|
||||
# If a plain dev branch exists (origin/dev), flag it as invalid.
|
||||
if [ "${#dev_branches[@]}" -gt 0 ]; then
|
||||
missing_required+=("invalid branch dev (must be dev/<version>)")
|
||||
fi
|
||||
@@ -559,48 +545,39 @@ jobs:
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
# ── Joomla-specific checks ───────────────────────────────────────
|
||||
# -- Joomla-specific checks --
|
||||
joomla_findings=()
|
||||
|
||||
# XML manifest: find any XML file containing <extension
|
||||
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
|
||||
if [ -z "${MANIFEST}" ]; then
|
||||
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
|
||||
else
|
||||
# Check <version> tag exists
|
||||
if ! grep -qP '<version>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <version> tag missing")
|
||||
fi
|
||||
# Check extension type attribute
|
||||
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: type attribute missing or invalid")
|
||||
fi
|
||||
# Check <name> tag
|
||||
if ! grep -qP '<name>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <name> tag missing")
|
||||
fi
|
||||
# Check <author> tag
|
||||
if ! grep -qP '<author>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <author> tag missing")
|
||||
fi
|
||||
# Check <namespace> for Joomla 5+
|
||||
if ! grep -qP '<namespace' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Language files: check for at least one .ini file
|
||||
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
|
||||
if [ "${INI_COUNT}" -eq 0 ]; then
|
||||
joomla_findings+=("No .ini language files found")
|
||||
fi
|
||||
|
||||
# updates.xml must exist in root (Joomla update server)
|
||||
if [ ! -f 'updates.xml' ]; then
|
||||
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
|
||||
fi
|
||||
|
||||
# index.html files for directory listing protection
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
@@ -630,14 +607,12 @@ jobs:
|
||||
extended_findings=()
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
# CODEOWNERS presence
|
||||
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
|
||||
:
|
||||
else
|
||||
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
|
||||
fi
|
||||
|
||||
# Workflow pinning advisory: flag uses @main/@master
|
||||
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
|
||||
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
|
||||
if [ -n "${bad_refs}" ]; then
|
||||
@@ -653,7 +628,6 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# Docs index link integrity (docs/docs-index.md)
|
||||
if [ -f "${DOCS_INDEX}" ]; then
|
||||
missing_links="$(python3 - <<'PY'
|
||||
import os
|
||||
@@ -697,7 +671,6 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# ShellCheck advisory
|
||||
if [ -d "${SCRIPT_DIR}" ]; then
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
sudo apt-get update -qq
|
||||
@@ -726,7 +699,6 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# SPDX header advisory for common source types
|
||||
spdx_missing=()
|
||||
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
|
||||
spdx_args=()
|
||||
@@ -749,9 +721,8 @@ jobs:
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
# Git hygiene advisory: branches older than 180 days (remote)
|
||||
stale_cutoff_days=180
|
||||
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 [...]
|
||||
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
|
||||
if [ -n "${stale_branches}" ]; then
|
||||
extended_findings+=("Stale remote branches detected (advisory)")
|
||||
{
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
name: 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
|
||||
@@ -0,0 +1,464 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Joomla
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/update-server.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
|
||||
#
|
||||
# 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 Joomla Update Server XML Feed
|
||||
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup MokoStandards 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
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
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/mokostandards-api/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/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
|
||||
if [ -n "$BUMPED" ]; then
|
||||
VERSION=$(php /tmp/mokostandards-api/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>' "/")
|
||||
|
||||
CLIENT_TAG=""
|
||||
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
|
||||
|
||||
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 suffix for non-stable
|
||||
DISPLAY_VERSION="$VERSION"
|
||||
case "$STABILITY" in
|
||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
||||
esac
|
||||
|
||||
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}-${DISPLAY_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}-${DISPLAY_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} (${DISPLAY_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}: ${DISPLAY_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
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/contents/updates.xml" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'content': '${CONTENT}',
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
|
||||
'branch': 'main'
|
||||
}))")" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|
||||
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# -- Permission check: admin or maintain role required --------
|
||||
ACTOR="${{ github.actor }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
admin|maintain|write) ;;
|
||||
*)
|
||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
|
||||
PORT="${DEV_PORT:-22}"
|
||||
REMOTE="${DEV_PATH%/}"
|
||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||
if [ -n "$DEV_KEY" ]; then
|
||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
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 | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,763 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
# -- PLATFORM DETECTION ---------------------------------------------------
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1 || true)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Step 1: Read version"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path .)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::No VERSION in README.md"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Step 1b: Bump version"
|
||||
id: bump
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
MOKO_API="/tmp/moko-platform-api/cli"
|
||||
BUMP=$(php ${MOKO_API}/version_bump.php --path . --minor)
|
||||
VERSION=$(echo "$BUMP" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true)
|
||||
[ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Bumped to: ${VERSION}"
|
||||
|
||||
- name: Check if already released
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
|
||||
TAG_EXISTS=false
|
||||
BRANCH_EXISTS=false
|
||||
|
||||
git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
|
||||
git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
|
||||
|
||||
echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Tag and branch may persist across patch releases — never skip
|
||||
echo "already_released=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# -- SANITY CHECKS -------------------------------------------------------
|
||||
- name: "Sanity: Pre-release validation"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
ERRORS=0
|
||||
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
echo "## Pre-Release Sanity Checks (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Version drift check (must pass before release) --------
|
||||
README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
||||
if [ "$README_VER" != "$VERSION" ]; then
|
||||
echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Check CHANGELOG version matches
|
||||
CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
|
||||
if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
|
||||
echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
|
||||
# Check composer.json version if present
|
||||
if [ -f "composer.json" ]; then
|
||||
COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
|
||||
if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
|
||||
echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Common checks
|
||||
if [ ! -f "LICENSE" ]; then
|
||||
echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
|
||||
echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- Platform-specific checks --------
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
XML_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
|
||||
echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- No Joomla XML manifest (WaaS site)" >> $GITHUB_STEP_SUMMARY
|
||||
fi ;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_VER=$(sed -n "s/.*\\\$this->version = '\([^']*\)'.*/\1/p" "$MOD_FILE" 2>/dev/null | head -1)
|
||||
if [ -n "$MOD_VER" ] && [ "$MOD_VER" != "$VERSION" ]; then
|
||||
echo "- Module drift: \`${MOD_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Module version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
echo "- No mod*.class.php found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
if [ ! -f "update.txt" ]; then
|
||||
echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi ;;
|
||||
*) echo "- Generic platform � no manifest checks" >> $GITHUB_STEP_SUMMARY ;;
|
||||
esac
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 2: Create or update version/XX.YY archive branch ---------------
|
||||
# Always runs — every version change on main archives to version/XX.YY
|
||||
- name: "Step 2: Version archive branch"
|
||||
if: steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
IS_MINOR="${{ steps.version.outputs.is_minor }}"
|
||||
PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
|
||||
|
||||
# Check if branch exists
|
||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
||||
git push origin HEAD:"$BRANCH" --force
|
||||
echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
|
||||
git push origin "$BRANCH" --force
|
||||
echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 3: Set platform version ----------------------------------------
|
||||
- name: "Step 3: Set platform version"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch main
|
||||
|
||||
# -- STEP 4: Update version badges ----------------------------------------
|
||||
- name: "Step 4: Update version badges"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/badge_update.php --path . --version "${VERSION}" 2>/dev/null || true
|
||||
|
||||
- name: "Step 5: Write update stream"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability stable \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
--github-output
|
||||
|
||||
- name: Commit release changes
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
if git diff --quiet && git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
# Set push URL with token for branch-protected repos
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git commit -m "chore(release): build ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push -u origin HEAD
|
||||
|
||||
# -- STEP 6: Create tag ---------------------------------------------------
|
||||
- name: "Step 6: Create git tag"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.tag_exists != 'true' &&
|
||||
steps.version.outputs.is_minor == 'true'
|
||||
run: |
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
# Only create the major release tag if it doesn't exist yet
|
||||
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
git tag "$RELEASE_TAG"
|
||||
git push origin "$RELEASE_TAG"
|
||||
echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 7: Create or update Gitea Release --------------------------------
|
||||
- name: "Step 7: Gitea Release"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
MAJOR="${{ steps.version.outputs.major }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Reuse metadata from Step 5 (single source of truth)
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
EXT_NAME="${{ steps.updates.outputs.ext_name }}"
|
||||
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
|
||||
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
|
||||
|
||||
# Fallbacks if Step 5 was skipped
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
|
||||
|
||||
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
|
||||
# Build release name: "Pretty Name VERSION (type_element-VERSION)"
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
|
||||
|
||||
# Delete existing release if present (overwrite, not append)
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true
|
||||
echo "Deleted previous stable release (id: ${EXISTING_ID})"
|
||||
fi
|
||||
|
||||
# Create fresh release
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/releases" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'tag_name': '${RELEASE_TAG}',
|
||||
'name': '${RELEASE_NAME}',
|
||||
'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''',
|
||||
'target_commitish': '${BRANCH}'
|
||||
}))")"
|
||||
echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
|
||||
- name: "Step 8: Build package and update checksum"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# All ZIPs upload to the major release tag (vXX)
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find extension element name from manifest
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
|
||||
# Reuse element from Step 5, with same fallback chain
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
# ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
|
||||
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
|
||||
|
||||
# -- Build install packages from src/ ----------------------------
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/"; exit 0; }
|
||||
|
||||
# ZIP package (type-aware via moko-platform PHP API)
|
||||
php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --output /tmp
|
||||
# Match the expected ZIP_NAME for upload
|
||||
BUILT_ZIP=$(ls /tmp/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip 2>/dev/null | head -1 || true)
|
||||
if [ -n "$BUILT_ZIP" ] && [ "$BUILT_ZIP" != "/tmp/${ZIP_NAME}" ]; then
|
||||
mv "$BUILT_ZIP" "/tmp/${ZIP_NAME}"
|
||||
fi
|
||||
|
||||
# tar.gz package (flat source archive)
|
||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" --exclude='.ftpignore' --exclude='sftp-config*' --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
||||
|
||||
ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
|
||||
TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
|
||||
|
||||
# -- Calculate SHA-256 for both ----------------------------------
|
||||
SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# -- 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_NAME in "$ZIP_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_NAME}':
|
||||
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 to release tag ----------------------------------
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${ZIP_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_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
|
||||
|
||||
# -- Update updates.xml with both download formats ---------------
|
||||
if [ -f "updates.xml" ]; then
|
||||
ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}"
|
||||
TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}"
|
||||
|
||||
# Use Python to update only the stable entry's downloads + sha256
|
||||
export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
with open("updates.xml") as f:
|
||||
content = f.read()
|
||||
|
||||
zip_url = os.environ["PY_ZIP_URL"]
|
||||
tar_url = os.environ["PY_TAR_URL"]
|
||||
sha = os.environ["PY_SHA"]
|
||||
|
||||
# Find the stable update block and replace its downloads + sha256
|
||||
def replace_stable(m):
|
||||
block = m.group(0)
|
||||
# Replace downloads block
|
||||
new_downloads = (
|
||||
" <downloads>\n"
|
||||
f" <downloadurl type=\"full\" format=\"zip\">{zip_url}</downloadurl>\n"
|
||||
" </downloads>"
|
||||
)
|
||||
block = re.sub(r' <downloads>.*?</downloads>', new_downloads, block, flags=re.DOTALL)
|
||||
# Add or replace sha256
|
||||
if '<sha256>' in block:
|
||||
block = re.sub(r' <sha256>.*?</sha256>', f' <sha256>{sha}</sha256>', block)
|
||||
else:
|
||||
block = block.replace('</downloads>', f'</downloads>\n <sha256>{sha}</sha256>')
|
||||
return block
|
||||
|
||||
content = re.sub(
|
||||
r' <update>.*?<tag>stable</tag>.*?</update>',
|
||||
replace_stable,
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git add updates.xml
|
||||
git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" || true
|
||||
git push || true
|
||||
|
||||
# Sync updates.xml to main via direct API (always runs — may be on version/XX branch)
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
|
||||
|
||||
if [ -n "$FILE_SHA" ]; then
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/contents/updates.xml" \
|
||||
-d "$(jq -n \
|
||||
--arg content "$CONTENT" \
|
||||
--arg sha "$FILE_SHA" \
|
||||
--arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
|
||||
--arg branch "main" \
|
||||
'{content: $content, sha: $sha, message: $msg, branch: $branch}'
|
||||
)" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main via API" \
|
||||
|| echo "WARNING: failed to sync updates.xml to main"
|
||||
else
|
||||
echo "WARNING: could not get updates.xml SHA from main"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "### Packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 8b: Update release description with changelog + SHA ----------------
|
||||
- name: "Step 8b: Update release body with changelog and SHA"
|
||||
if: steps.version.outputs.skip != '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}"
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
|
||||
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
|
||||
|
||||
# Build TYPE_PREFIX to match Step 8's ZIP naming
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
|
||||
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
|
||||
|
||||
# Get SHA from the built files
|
||||
SHA256_ZIP=""
|
||||
[ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
SHA256_TAR=""
|
||||
[ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# Extract latest changelog entry (strip the ## header to avoid duplicate)
|
||||
CHANGELOG=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
|
||||
fi
|
||||
|
||||
# Build release body (single header, no duplicate from changelog)
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
|
||||
if [ -n "$CHANGELOG" ]; then
|
||||
BODY="${BODY}${CHANGELOG}\n\n"
|
||||
fi
|
||||
BODY="${BODY}---\n\n### Checksums\n\n"
|
||||
BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
|
||||
[ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
|
||||
[ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
|
||||
|
||||
# Get release ID and update body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = '''$(printf '%b' "$BODY")'''
|
||||
data = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=data,
|
||||
headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
|
||||
method='PATCH'
|
||||
)
|
||||
urllib.request.urlopen(req)
|
||||
" 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.version.outputs.stability == 'stable' &&
|
||||
secrets.GH_TOKEN != ''
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
MAJOR="${{ steps.version.outputs.major }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
|
||||
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
echo "$NOTES" > /tmp/release_notes.md
|
||||
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
|
||||
|
||||
if [ -z "$EXISTING" ]; then
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--repo "$GH_REPO" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" \
|
||||
--notes-file /tmp/release_notes.md \
|
||||
--target "$BRANCH" || true
|
||||
else
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--repo "$GH_REPO" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" || true
|
||||
fi
|
||||
|
||||
# Upload assets to GitHub mirror
|
||||
for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
|
||||
if [ -f "$PKG" ]; then
|
||||
_RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
|
||||
[ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
# -- Clean up lesser pre-releases (cascade) ---------------------------------
|
||||
# stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev
|
||||
- name: "Delete lesser pre-release channels"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_cascade.php \
|
||||
--stability stable \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
--gitea-url "${GITEA_URL}" 2>/dev/null || true
|
||||
|
||||
- name: "Step 11: Delete and recreate dev branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Dolibarr: Reset dev version"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.platform.outputs.platform == 'dolibarr' &&
|
||||
steps.platform.outputs.mod_file != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
ENCODED_PATH=$(echo "$MOD_FILE" | sed 's|^\./||' | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))")
|
||||
FILE_RESP=$(curl -sf -H "Authorization: token ${TOKEN}" "${API_BASE}/contents/${ENCODED_PATH}?ref=dev" 2>/dev/null || true)
|
||||
FILE_SHA=$(echo "$FILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
FILE_CONTENT=$(echo "$FILE_RESP" | python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin).get('content','')).decode())" 2>/dev/null || true)
|
||||
if [ -n "$FILE_SHA" ] && [ -n "$FILE_CONTENT" ]; then
|
||||
UPDATED=$(echo "$FILE_CONTENT" | sed "s/\$this->version = '[^']*'/\$this->version = 'development'/")
|
||||
ENCODED=$(echo "$UPDATED" | base64 -w0)
|
||||
curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/contents/${ENCODED_PATH}" \
|
||||
-d "$(jq -n --arg content \"$ENCODED\" --arg sha \"$FILE_SHA\" --arg msg \"chore(version): reset dev version [skip ci]\" --arg branch \"dev\" '{content:$content,sha:$sha,message:$msg,branch:$branch}')" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -0,0 +1,213 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/cascade-dev.yml.template
|
||||
# VERSION: 02.00.00
|
||||
# BRIEF: Forward-merge main → all open branches after every push to main
|
||||
#
|
||||
# +========================================================================+
|
||||
# | CASCADE MAIN → ALL BRANCHES |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Triggers on every push to main (PR merges, bot commits, etc.) |
|
||||
# | |
|
||||
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
|
||||
# | 2. For each: create PR (main → branch), auto-merge if clean |
|
||||
# | 3. On conflict: leave PR open for manual resolution |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Cascade Main → Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
cascade:
|
||||
name: Cascade main → branches
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
||||
|
||||
steps:
|
||||
- name: Discover target branches
|
||||
id: branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Fetch all branches (paginated)
|
||||
PAGE=1
|
||||
ALL_BRANCHES=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
|
||||
TARGETS=""
|
||||
for BRANCH in $ALL_BRANCHES; do
|
||||
case "$BRANCH" in
|
||||
dev|dev/*|rc/*|beta/*|alpha/*)
|
||||
TARGETS="$TARGETS $BRANCH"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
|
||||
|
||||
if [ -z "$TARGETS" ]; then
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "ℹ️ No cascade target branches found"
|
||||
else
|
||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$TARGETS" | wc -w)
|
||||
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
|
||||
fi
|
||||
|
||||
- name: Cascade to all target branches
|
||||
if: steps.branches.outputs.targets != ''
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TARGETS="${{ steps.branches.outputs.targets }}"
|
||||
|
||||
SUCCESS=0
|
||||
CONFLICTS=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
for BRANCH in $TARGETS; do
|
||||
echo ""
|
||||
echo "═══ main → ${BRANCH} ═══"
|
||||
|
||||
# Check if branch is already up to date
|
||||
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
|
||||
RESPONSE=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/compare/${ENCODED_BRANCH}...main")
|
||||
|
||||
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
|
||||
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo " ✅ Already up to date"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ℹ️ main is ${AHEAD} commit(s) ahead"
|
||||
|
||||
# Check for existing cascade PR
|
||||
EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
|
||||
|
||||
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
|
||||
PR_NUMBER=""
|
||||
|
||||
if [ "$EXISTING_COUNT" -gt 0 ]; then
|
||||
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
|
||||
echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
|
||||
else
|
||||
# Create cascade PR
|
||||
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
|
||||
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
|
||||
\"head\": \"main\",
|
||||
\"base\": \"${BRANCH}\"
|
||||
}" \
|
||||
"${API}/pulls")
|
||||
|
||||
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
||||
BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
||||
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
|
||||
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
|
||||
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ✅ Created PR #${PR_NUMBER}"
|
||||
fi
|
||||
|
||||
# Try auto-merge
|
||||
PR_DATA=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}")
|
||||
|
||||
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
|
||||
|
||||
if [ "$MERGEABLE" != "true" ]; then
|
||||
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"Do\": \"merge\",
|
||||
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
|
||||
\"delete_branch_after_merge\": false
|
||||
}" \
|
||||
"${API}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
|
||||
|
||||
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
||||
echo " ✅ Merged — ${BRANCH} is in sync"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
|
||||
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Merged: ${SUCCESS}"
|
||||
echo " ⚠️ Conflicts: ${CONFLICTS}"
|
||||
echo " ⏭️ Up to date: ${SKIPPED}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,450 @@
|
||||
# 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: Gitea.Workflow.Template
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/ci-joomla.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: CI workflow for Joomla extensions — lint, validate, test
|
||||
|
||||
name: "Joomla: Extension CI"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- 'dev/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
lint-and-validate:
|
||||
name: Lint & Validate
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Clone MokoStandards
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
--no-interaction \
|
||||
--prefer-dist \
|
||||
--optimize-autoloader
|
||||
else
|
||||
echo "No composer.json found — skipping dependency install"
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
run: |
|
||||
ERRORS=0
|
||||
for DIR in src/ htdocs/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
FOUND=1
|
||||
while IFS= read -r -d '' FILE; do
|
||||
OUTPUT=$(php -l "$FILE" 2>&1)
|
||||
if echo "$OUTPUT" | grep -q "Parse error"; then
|
||||
echo "::error file=${FILE}::${OUTPUT}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$DIR" -name "*.php" -print0)
|
||||
fi
|
||||
done
|
||||
echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: XML manifest validation
|
||||
run: |
|
||||
echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find the extension manifest (XML with <extension tag)
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No Joomla extension manifest found (XML file with \`<extension\` tag)." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Validate well-formed XML
|
||||
php -r "
|
||||
\$xml = @simplexml_load_file('$MANIFEST');
|
||||
if (\$xml === false) {
|
||||
echo 'INVALID';
|
||||
exit(1);
|
||||
}
|
||||
echo 'VALID';
|
||||
" > /tmp/xml_result 2>&1
|
||||
XML_RESULT=$(cat /tmp/xml_result)
|
||||
if [ "$XML_RESULT" != "VALID" ]; then
|
||||
echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Check required tags: name, version, author, namespace (Joomla 5+)
|
||||
for TAG in name version author namespace; do
|
||||
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
|
||||
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Check language files referenced in manifest
|
||||
run: |
|
||||
echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
# Extract language file references from manifest
|
||||
LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
if [ -z "$LANG_FILES" ]; then
|
||||
echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
while IFS= read -r LANG_FILE; do
|
||||
LANG_FILE=$(echo "$LANG_FILE" | xargs)
|
||||
if [ -z "$LANG_FILE" ]; then
|
||||
continue
|
||||
fi
|
||||
# Check in common locations
|
||||
FOUND=0
|
||||
for BASE in "." "src" "htdocs"; do
|
||||
if [ -f "${BASE}/${LANG_FILE}" ]; then
|
||||
FOUND=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$FOUND" -eq 0 ]; then
|
||||
echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
done <<< "$LANG_FILES"
|
||||
fi
|
||||
else
|
||||
echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Check index.html files in directories
|
||||
run: |
|
||||
echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=0
|
||||
CHECKED=0
|
||||
|
||||
for DIR in src/ htdocs/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
while IFS= read -r -d '' SUBDIR; do
|
||||
CHECKED=$((CHECKED + 1))
|
||||
if [ ! -f "${SUBDIR}/index.html" ]; then
|
||||
echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$DIR" -type d -print0)
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${CHECKED}" -eq 0 ]; then
|
||||
echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${MISSING}" -gt 0 ]; then
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
release-readiness:
|
||||
name: Release Readiness Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Validate release readiness
|
||||
run: |
|
||||
echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Extract version from README.md
|
||||
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
|
||||
if [ -z "$README_VERSION" ]; then
|
||||
echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Find the extension manifest
|
||||
MANIFEST=""
|
||||
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||
MANIFEST="$XML_FILE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check <version> matches README VERSION
|
||||
MANIFEST_VERSION=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||
if [ -z "$MANIFEST_VERSION" ]; then
|
||||
echo "No \`<version>\` tag in manifest." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then
|
||||
echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Check extension type, element, client attributes
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||
if [ -z "$EXT_TYPE" ]; then
|
||||
echo "Missing \`type\` attribute on \`<extension>\` tag." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Element check (component/module/plugin name)
|
||||
HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0")
|
||||
if [ "$HAS_ELEMENT" -eq 0 ]; then
|
||||
echo "Missing \`<element>\` or \`<name>\` in manifest." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Client attribute for site/admin modules and plugins
|
||||
if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then
|
||||
HAS_CLIENT=$(grep -cP '<extension[^>]*\bclient=' "$MANIFEST" 2>/dev/null || echo "0")
|
||||
if [ "$HAS_CLIENT" -eq 0 ]; then
|
||||
echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check updates.xml exists
|
||||
if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then
|
||||
echo "Update XML present." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check CHANGELOG.md exists
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Tests (PHP ${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-validate
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['8.2', '8.3']
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
--no-interaction \
|
||||
--prefer-dist \
|
||||
--optimize-autoloader
|
||||
else
|
||||
echo "No composer.json found — skipping dependency install"
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||
if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
|
||||
vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log
|
||||
EXIT=${PIPESTATUS[0]}
|
||||
if [ $EXIT -eq 0 ]; then
|
||||
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
exit $EXIT
|
||||
else
|
||||
echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
static-analysis:
|
||||
name: PHPStan Analysis
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-validate
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: php -v && composer --version
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
fi
|
||||
|
||||
- name: Install PHPStan
|
||||
run: |
|
||||
if ! command -v vendor/bin/phpstan &> /dev/null; then
|
||||
composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \
|
||||
composer global require phpstan/phpstan --no-interaction
|
||||
fi
|
||||
|
||||
- name: Run PHPStan
|
||||
run: |
|
||||
echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY
|
||||
PHPSTAN="vendor/bin/phpstan"
|
||||
if [ ! -f "$PHPSTAN" ]; then
|
||||
PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan
|
||||
fi
|
||||
|
||||
# Determine source directory
|
||||
SRC_DIR=""
|
||||
for DIR in src/ htdocs/ lib/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
SRC_DIR="$DIR"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use repo phpstan.neon if present, otherwise use baseline config
|
||||
ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table"
|
||||
if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then
|
||||
echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
ARGS="$ARGS --level=3"
|
||||
echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
$PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt
|
||||
EXIT=${PIPESTATUS[0]}
|
||||
|
||||
if [ $EXIT -eq 0 ]; then
|
||||
echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some")
|
||||
echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
exit $EXIT
|
||||
@@ -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,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,196 @@
|
||||
# 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; }
|
||||
@@ -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.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
runs-on: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup PHP
|
||||
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 php-zip php-curl >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" /tmp/moko-platform-api
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
MOKO_API="/tmp/moko-platform-api/cli"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Bump patch version
|
||||
BUMP_OUTPUT=$(php ${MOKO_API}/version_bump.php --path .)
|
||||
VERSION=$(echo "$BUMP_OUTPUT" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true)
|
||||
[ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .)
|
||||
echo "Version: ${VERSION}"
|
||||
|
||||
# Update platform-specific manifest
|
||||
php ${MOKO_API}/version_set_platform.php --path . --version "${VERSION}"
|
||||
|
||||
# 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): bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Detect element from Joomla/Dolibarr manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
EXT_ELEMENT=$(php ${MOKO_API}/manifest_read.php --path . --field name 2>/dev/null | tr -d ' ' | tr '[:upper:]' '[:lower:]' || true)
|
||||
# For Joomla, prefer <element> tag
|
||||
if [ "$PLATFORM" = "joomla" ]; then
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
ELEM=$(grep -oP "<element>\K[^<]+" "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -n "$ELEM" ] && EXT_ELEMENT="$ELEM"
|
||||
fi
|
||||
fi
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
id: zip
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SUFFIX="${{ steps.meta.outputs.suffix }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
|
||||
if [ "$PLATFORM" = "joomla" ]; then
|
||||
php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --suffix "${SUFFIX}" --output build --github-output
|
||||
else
|
||||
# Generic build: zip src/ directory
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "::error::No src/ or htdocs/"; exit 1; }
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
mkdir -p build
|
||||
cd "$SOURCE_DIR" && zip -r "../build/${ZIP_NAME}" . && cd ..
|
||||
SHA256=$(sha256sum "build/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_path=build/${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
id: release
|
||||
continue-on-error: true
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.zip.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@${{ steps.zip.outputs.zip_path }}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: "Update updates.xml"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
php /tmp/moko-platform-api/cli/updates_xml_build.php --path . --version "$VERSION" --stability "$STABILITY" --sha "$SHA256" --gitea-url "$GITEA_URL" --org "$GITEA_ORG" --repo "$GITEA_REPO"
|
||||
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: |
|
||||
php /tmp/moko-platform-api/cli/updates_xml_sync.php --path . --current "${{ github.ref_name }}" --branches main,dev --version "${{ steps.meta.outputs.version }}" --token "${{ secrets.GA_TOKEN }}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" --gitea-url "${GITEA_URL}"
|
||||
|
||||
- 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 }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
|
||||
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
|
||||
case "$STABILITY" in
|
||||
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
|
||||
beta) TAGS_TO_DELETE="alpha development" ;;
|
||||
alpha) TAGS_TO_DELETE="development" ;;
|
||||
*) TAGS_TO_DELETE="" ;;
|
||||
esac
|
||||
|
||||
[ -z "$TAGS_TO_DELETE" ] && exit 0
|
||||
|
||||
for TAG in $TAGS_TO_DELETE; do
|
||||
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
|
||||
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,769 @@
|
||||
# ============================================================================
|
||||
# Copyright (C) 2025 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: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Generic: Repo Health"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Release policy - Repository Variables Only
|
||||
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
|
||||
# Repo health policy
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
|
||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||
REPO_DISALLOWED_DIRS:
|
||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||
|
||||
# Extended checks toggles
|
||||
EXTENDED_CHECKS: "true"
|
||||
|
||||
# File / directory variables
|
||||
DOCS_INDEX: docs/docs-index.md
|
||||
SCRIPT_DIR: scripts
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
SHELLCHECK_PATTERN: '*.sh'
|
||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
access_check:
|
||||
name: Access control
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
outputs:
|
||||
allowed: ${{ steps.perm.outputs.allowed }}
|
||||
permission: ${{ steps.perm.outputs.permission }}
|
||||
|
||||
steps:
|
||||
- name: Check actor permission (admin only)
|
||||
id: perm
|
||||
env:
|
||||
TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ALLOWED=false
|
||||
PERMISSION=unknown
|
||||
METHOD=""
|
||||
|
||||
# Hardcoded authorized users — always allowed
|
||||
case "$ACTOR" in
|
||||
jmiller|gitea-actions[bot])
|
||||
ALLOWED=true
|
||||
PERMISSION=admin
|
||||
METHOD="hardcoded allowlist"
|
||||
;;
|
||||
*)
|
||||
# Detect platform and check permissions via API
|
||||
API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
|
||||
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
|
||||
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
|
||||
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
|
||||
ALLOWED=true
|
||||
fi
|
||||
METHOD="collaborator API"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "## Access Authorization"
|
||||
echo ""
|
||||
echo "| Field | Value |"
|
||||
echo "|-------|-------|"
|
||||
echo "| **Actor** | \`${ACTOR}\` |"
|
||||
echo "| **Repository** | \`${REPO}\` |"
|
||||
echo "| **Permission** | \`${PERMISSION}\` |"
|
||||
echo "| **Method** | ${METHOD} |"
|
||||
echo "| **Authorized** | ${ALLOWED} |"
|
||||
echo ""
|
||||
if [ "$ALLOWED" = "true" ]; then
|
||||
echo "${ACTOR} authorized (${METHOD})"
|
||||
else
|
||||
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
|
||||
fi
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Deny execution when not permitted
|
||||
if: ${{ steps.perm.outputs.allowed != 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
release_config:
|
||||
name: Release configuration
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guardrails release vars
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes release validation'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
|
||||
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
|
||||
|
||||
missing=()
|
||||
missing_optional=()
|
||||
|
||||
for k in "${required[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing+=("${k}")
|
||||
done
|
||||
|
||||
for k in "${optional[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing_optional+=("${k}")
|
||||
done
|
||||
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Variable | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
|
||||
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repository variables'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repository variables'
|
||||
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository variables validation result'
|
||||
printf '%s\n' 'Status: OK'
|
||||
printf '%s\n' 'All required repository variables present.'
|
||||
printf '%s\n' ''
|
||||
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scripts folder checks
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes scripts governance'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "${SCRIPT_DIR}" ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' 'Status: OK (advisory)'
|
||||
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi
|
||||
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
|
||||
|
||||
missing_dirs=()
|
||||
unapproved_dirs=()
|
||||
|
||||
for d in "${required_dirs[@]}"; do
|
||||
req="${d%/}"
|
||||
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
|
||||
done
|
||||
|
||||
while IFS= read -r d; do
|
||||
allowed=false
|
||||
for a in "${allowed_dirs[@]}"; do
|
||||
a_norm="${a%/}"
|
||||
[ "${d%/}" = "${a_norm}" ] && allowed=true
|
||||
done
|
||||
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
|
||||
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
|
||||
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Area | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
|
||||
if [ "${#missing_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
|
||||
else
|
||||
printf '%s\n' '| Required directories | OK | All required subfolders present |'
|
||||
fi
|
||||
|
||||
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
|
||||
else
|
||||
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
|
||||
fi
|
||||
|
||||
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
|
||||
printf '\n'
|
||||
|
||||
if [ "${#missing_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' 'Missing required script directories:'
|
||||
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
else
|
||||
printf '%s\n' 'Missing required script directories: none.'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' 'Unapproved script directories detected:'
|
||||
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
else
|
||||
printf '%s\n' 'Unapproved script directories detected: none.'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Scripts governance completed in advisory mode.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
repo_health:
|
||||
name: Repository health
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Repository health checks
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes repository health'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid for extension repos)
|
||||
SOURCE_DIR=""
|
||||
if [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||
# Platform/tooling repos don't need src/
|
||||
SOURCE_DIR=""
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
if printf '%s' "${item}" | grep -q '/$'; then
|
||||
d="${item%/}"
|
||||
[ ! -d "${d}" ] && missing_required+=("${item}")
|
||||
else
|
||||
[ ! -f "${item}" ] && missing_required+=("${item}")
|
||||
fi
|
||||
done
|
||||
|
||||
for f in "${optional_files[@]}"; do
|
||||
if printf '%s' "${f}" | grep -q '/$'; then
|
||||
d="${f%/}"
|
||||
[ ! -d "${d}" ] && missing_optional+=("${f}")
|
||||
else
|
||||
[ ! -f "${f}" ] && missing_optional+=("${f}")
|
||||
fi
|
||||
done
|
||||
|
||||
for d in "${disallowed_dirs[@]}"; do
|
||||
d_norm="${d%/}"
|
||||
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
|
||||
done
|
||||
|
||||
for f in "${disallowed_files[@]}"; do
|
||||
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
|
||||
done
|
||||
|
||||
git fetch origin --prune
|
||||
|
||||
dev_paths=()
|
||||
dev_branches=()
|
||||
|
||||
while IFS= read -r b; do
|
||||
name="${b#origin/}"
|
||||
if [ "${name}" = 'dev' ]; then
|
||||
dev_branches+=("${name}")
|
||||
else
|
||||
dev_paths+=("${name}")
|
||||
fi
|
||||
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
||||
|
||||
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev or dev/* branch")
|
||||
fi
|
||||
|
||||
content_warnings=()
|
||||
|
||||
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
|
||||
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
|
||||
fi
|
||||
|
||||
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
|
||||
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
|
||||
fi
|
||||
|
||||
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
|
||||
content_warnings+=("LICENSE does not look like a GPL text")
|
||||
fi
|
||||
|
||||
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
|
||||
content_warnings+=("README.md missing expected brand keyword")
|
||||
fi
|
||||
|
||||
export PROFILE_RAW="${profile}"
|
||||
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
|
||||
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
|
||||
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
|
||||
|
||||
report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Metric | Value |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
|
||||
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
|
||||
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
|
||||
printf '\n'
|
||||
|
||||
printf '%s\n' '### Guardrails report (JSON)'
|
||||
printf '%s\n' '```json'
|
||||
printf '%s\n' "${report_json}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_required[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repo artifacts'
|
||||
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repo artifacts'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#content_warnings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Repo content warnings'
|
||||
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
# -- Joomla-specific checks --
|
||||
joomla_findings=()
|
||||
|
||||
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
|
||||
if [ -z "${MANIFEST}" ]; then
|
||||
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
|
||||
else
|
||||
if ! grep -qP '<version>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <version> tag missing")
|
||||
fi
|
||||
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: type attribute missing or invalid")
|
||||
fi
|
||||
if ! grep -qP '<name>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <name> tag missing")
|
||||
fi
|
||||
if ! grep -qP '<author>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <author> tag missing")
|
||||
fi
|
||||
if ! grep -qP '<namespace' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
|
||||
fi
|
||||
fi
|
||||
|
||||
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
|
||||
if [ "${INI_COUNT}" -eq 0 ]; then
|
||||
joomla_findings+=("No .ini language files found")
|
||||
fi
|
||||
|
||||
if [ ! -f 'updates.xml' ]; then
|
||||
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
|
||||
fi
|
||||
|
||||
if [ -n "${SOURCE_DIR}" ]; then
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#joomla_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Joomla extension checks'
|
||||
printf '%s\n' '| Check | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
for f in "${joomla_findings[@]}"; do
|
||||
printf '%s\n' "| ${f} | Warning |"
|
||||
done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
else
|
||||
{
|
||||
printf '%s\n' '### Joomla extension checks'
|
||||
printf '%s\n' 'All Joomla-specific checks passed.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
extended_enabled="${EXTENDED_CHECKS:-true}"
|
||||
extended_findings=()
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
|
||||
:
|
||||
else
|
||||
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
|
||||
fi
|
||||
|
||||
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
|
||||
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
|
||||
if [ -n "${bad_refs}" ]; then
|
||||
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
|
||||
{
|
||||
printf '%s\n' '### Workflow pinning advisory'
|
||||
printf '%s\n' 'Found uses: entries pinned to main/master:'
|
||||
printf '%s\n' '```'
|
||||
printf '%s\n' "${bad_refs}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "${DOCS_INDEX}" ]; then
|
||||
missing_links=""
|
||||
while IFS= read -r docline; do
|
||||
for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
|
||||
case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
|
||||
linkpath="${link%%#*}"
|
||||
linkpath="${linkpath%%\?*}"
|
||||
[ -z "$linkpath" ] && continue
|
||||
if [ "${linkpath:0:1}" = "/" ]; then
|
||||
testpath="${linkpath#/}"
|
||||
else
|
||||
testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
|
||||
fi
|
||||
[ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
|
||||
done
|
||||
done < "${DOCS_INDEX}"
|
||||
if [ -n "${missing_links}" ]; then
|
||||
extended_findings+=("docs/docs-index.md contains broken relative links")
|
||||
{
|
||||
printf '%s\n' '### Docs index link integrity'
|
||||
printf '%s\n' 'Broken relative links:'
|
||||
for bl in ${missing_links}; do
|
||||
printf '%s\n' "- ${bl}"
|
||||
done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -d "${SCRIPT_DIR}" ]; then
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y shellcheck >/dev/null
|
||||
fi
|
||||
|
||||
sc_out=''
|
||||
while IFS= read -r shf; do
|
||||
[ -z "${shf}" ] && continue
|
||||
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
|
||||
if [ -n "${out_one}" ]; then
|
||||
sc_out="${sc_out}${out_one}\n"
|
||||
fi
|
||||
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
|
||||
|
||||
if [ -n "${sc_out}" ]; then
|
||||
extended_findings+=("ShellCheck warnings detected (advisory)")
|
||||
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
|
||||
{
|
||||
printf '%s\n' '### ShellCheck (advisory)'
|
||||
printf '%s\n' '```'
|
||||
printf '%s\n' "${sc_head}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
spdx_missing=()
|
||||
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
|
||||
spdx_args=()
|
||||
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
|
||||
|
||||
while IFS= read -r f; do
|
||||
[ -z "${f}" ] && continue
|
||||
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
|
||||
spdx_missing+=("${f}")
|
||||
fi
|
||||
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
|
||||
|
||||
if [ "${#spdx_missing[@]}" -gt 0 ]; then
|
||||
extended_findings+=("SPDX header missing in some tracked files (advisory)")
|
||||
{
|
||||
printf '%s\n' '### SPDX header advisory'
|
||||
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
|
||||
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
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 > days*86400) print $1}' | head -50)"
|
||||
if [ -n "${stale_branches}" ]; then
|
||||
extended_findings+=("Stale remote branches detected (advisory)")
|
||||
{
|
||||
printf '%s\n' '### Git hygiene advisory'
|
||||
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
|
||||
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Guardrails coverage matrix'
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release variables | OK | Repository variables validation |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
if [ "${#extended_findings[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
|
||||
else
|
||||
printf '%s\n' '| Extended checks | OK | No findings |'
|
||||
fi
|
||||
else
|
||||
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
|
||||
fi
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Extended findings (advisory)'
|
||||
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
|
||||
site-health:
|
||||
name: Site Health
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
|
||||
- name: Uptime check
|
||||
if: env.URLS != ''
|
||||
run: |
|
||||
echo "$URLS" > /tmp/urls.txt
|
||||
php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down"
|
||||
rm -f /tmp/urls.txt
|
||||
env:
|
||||
URLS: ${{ vars.MONITORED_URLS }}
|
||||
|
||||
- name: SSL certificate check
|
||||
if: env.DOMAINS != ''
|
||||
run: |
|
||||
echo "$DOMAINS" > /tmp/domains.txt
|
||||
php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon"
|
||||
rm -f /tmp/domains.txt
|
||||
env:
|
||||
DOMAINS: ${{ vars.MONITORED_DOMAINS }}
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
name: "Universal: Security Audit"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Composer audit
|
||||
if: hashFiles('composer.lock') != ''
|
||||
run: |
|
||||
echo "=== Composer Security Audit ==="
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
||||
fi
|
||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
||||
RESULT=$?
|
||||
if [ $RESULT -ne 0 ]; then
|
||||
echo "::warning::Composer vulnerabilities found"
|
||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No known vulnerabilities in composer dependencies"
|
||||
fi
|
||||
|
||||
- name: NPM audit
|
||||
if: hashFiles('package-lock.json') != ''
|
||||
run: |
|
||||
echo "=== NPM Security Audit ==="
|
||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
||||
echo "No known vulnerabilities in npm dependencies"
|
||||
else
|
||||
echo "::warning::NPM vulnerabilities found"
|
||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Notify on vulnerabilities
|
||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
||||
-H "Tags: lock,warning" \
|
||||
-H "Priority: high" \
|
||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
|
||||
|
||||
- name: Joomla version audit
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
|
||||
echo "$JOOMLA_SITES" > /tmp/sites.json
|
||||
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
|
||||
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
|
||||
rm -f /tmp/sites.json
|
||||
else
|
||||
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
|
||||
fi
|
||||
env:
|
||||
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Joomla
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/update-server.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
|
||||
#
|
||||
# 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: "Joomla: 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup MokoStandards 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
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
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/mokostandards-api/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/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
|
||||
if [ -n "$BUMPED" ]; then
|
||||
VERSION=$(php /tmp/mokostandards-api/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>' "/")
|
||||
|
||||
CLIENT_TAG=""
|
||||
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
|
||||
|
||||
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 suffix for non-stable
|
||||
DISPLAY_VERSION="$VERSION"
|
||||
case "$STABILITY" in
|
||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
||||
esac
|
||||
|
||||
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}-${DISPLAY_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}-${DISPLAY_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} (${DISPLAY_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}: ${DISPLAY_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
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/contents/updates.xml" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'content': '${CONTENT}',
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
|
||||
'branch': 'main'
|
||||
}))")" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|
||||
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# -- Permission check: admin or maintain role required --------
|
||||
ACTOR="${{ github.actor }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
admin|maintain|write) ;;
|
||||
*)
|
||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
|
||||
PORT="${DEV_PORT:-22}"
|
||||
REMOTE="${DEV_PATH%/}"
|
||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||
if [ -n "$DEV_KEY" ]; then
|
||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
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 | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,20 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: MokoStandards.Templates.Config
|
||||
# INGROUP: MokoStandards.Templates
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/configs/moko-standards.yml
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: Governance attachment template — synced to .mokostandards in every governed repository
|
||||
# NOTE: Tokens replaced at sync time: mokoconsulting-tech, MokoJoomTOS, waas-component, 04.04.00
|
||||
#
|
||||
# This file is managed automatically by MokoStandards bulk sync.
|
||||
# Do not edit manually — changes will be overwritten on the next sync.
|
||||
# To update governance settings, open a PR in MokoStandards instead:
|
||||
# https://github.com/mokoconsulting-tech/MokoStandards
|
||||
|
||||
standards_source: "https://github.com/mokoconsulting-tech/MokoStandards"
|
||||
standards_version: "04.04.00"
|
||||
platform: "waas-component"
|
||||
governed_repo: "mokoconsulting-tech/MokoJoomTOS"
|
||||
+89
-112
@@ -21,8 +21,8 @@
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: MokoJoomTOS
|
||||
INGROUP: plg_system_mokojoomtos
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoJoomTOS
|
||||
VERSION: 03.08.04
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS
|
||||
VERSION: 04.02.01
|
||||
PATH: ./CHANGELOG.md
|
||||
BRIEF: Version history and release notes
|
||||
-->
|
||||
@@ -36,134 +36,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Planned
|
||||
|
||||
- GitHub Actions CI/CD workflows
|
||||
- Pre-commit hooks for code quality
|
||||
- Docker development environment
|
||||
## [04.02.01] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Legacy `mokojoomtos.php` now uses `onAfterRoute` event instead of `onAfterInitialise`
|
||||
- Legacy plugin entry point now sets `tmpl=component` for component-only view during offline mode
|
||||
- Installation script language keys corrected to match actual INI file definitions
|
||||
- Installation script `$extension` property corrected from `plg_system_mokojoomtos_offline` to `plg_system_mokojoomtos`
|
||||
- README.md event reference corrected from `onAfterInitialise` to `onAfterRoute`
|
||||
- enablePlugin() now called unconditionally in postflight() (#89)
|
||||
- Replaced Table::addIncludePath() with bootComponent()->getMVCFactory() for Joomla 5 (#90)
|
||||
- Added non-SEF URL fallback via Itemid matching (#91)
|
||||
- enablePlugin() now fires on upgrade path (#92)
|
||||
- Removed $_GET superglobal mutation (#93)
|
||||
- Added params, metadata, attribs defaults to article creation (#94)
|
||||
- Applied urldecode() to URI path before slug comparison (#95)
|
||||
- Cast Registry return to array before iterating slugs (#96)
|
||||
- Fixed MenuslugField separator `disable` to `disabled` property (#99)
|
||||
- Hardcode description in XML manifest (language variables don't resolve during install)
|
||||
- Synced VERSION header in manifest to 04.02.01 (#105)
|
||||
- Added SEF_WARNING language key to site-side .ini files (#106)
|
||||
- Fixed updates.xml version mismatch and Joomla 4.x targetplatform (#107)
|
||||
- Changed catch(Exception) to catch(Throwable) in script.php and Extension class (#108, #113)
|
||||
- Fixed dev channel targetplatform to include Joomla 4.x (#111)
|
||||
- Fixed misleading article duplicate check comment (#98)
|
||||
|
||||
### Added
|
||||
|
||||
- SEF disabled warning in MenuslugField dropdown (#97)
|
||||
- Include Children toggle for offline-accessible menu items (defaults to Yes)
|
||||
- Auto-select default menu slugs (terms-of-service, privacy-policy) on fresh install
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed deploy-manual.yml workflow — switching to Joomla update server method for extension distribution
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated all markdown FILE INFORMATION headers to reference MokoJoomTOS repository
|
||||
- Updated CONTRIBUTING.md title, clone URLs, and links to reference MokoJoomTOS
|
||||
- Updated CHANGELOG.md links to reference MokoJoomTOS repository
|
||||
- Updated CLAUDE.md and copilot-instructions.md for accuracy
|
||||
- Stripped legacy mokojoomtos.php to minimal stub (#101)
|
||||
- Converted script.php indentation from spaces to tabs (#102)
|
||||
- Renamed installer class to PlgSystemMokojoomtosInstallerScript (#103)
|
||||
- Promoted CHANGELOG [Unreleased] to versioned section (#112)
|
||||
|
||||
## [03.08.04] - 2026-02-28
|
||||
## [04.01.00] - 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use literal display name in manifest `<name>` tag so Joomla stores "System - Moko Terms of Service" directly in the DB
|
||||
- Updated plugin help text to describe multi-select workflow
|
||||
|
||||
## [04.00.00] - 2026-05-16
|
||||
|
||||
### Added
|
||||
|
||||
- Multi-select support: configure multiple menu items to remain accessible during offline mode
|
||||
- `services/provider.php` for Joomla 5 dependency injection container registration
|
||||
- Gitea-hosted update server URL in plugin manifest
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated version number to 03.08.04 across all files
|
||||
- Match against full menu route path instead of alias only (fixes nested routes like `/legal/terms-of-service`)
|
||||
- Plugin pretty name updated to Joomla convention: "System - Moko Terms of Service"
|
||||
- MenuslugField now stores and displays full route paths (e.g., `legal/terms-of-service`)
|
||||
|
||||
### Removed
|
||||
|
||||
- GitHub update server references (fully migrated to Gitea)
|
||||
- Legacy `src/plugins/` directory
|
||||
|
||||
## [03.09.00] - 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Installation script now resolves Uncategorised category ID dynamically (no longer assumes ID 2)
|
||||
- Installation script now resolves `com_content` component ID dynamically (removed hardcoded fallback 22)
|
||||
- All `Exception` catches qualified with backslash for namespace safety
|
||||
- Added missing `PLG_SYSTEM_MOKOJOOMTOS_ERROR_LOADING_MENU_ITEMS` language key to all locale files
|
||||
- Standardized help text URL across all locale files
|
||||
- Articles created during install are now owned by the installing admin user (not user ID 0)
|
||||
|
||||
### Changed
|
||||
|
||||
- Added Gitea update server URL to plugin manifest (`updates.xml` on `main`)
|
||||
- Fixed template chrome loading issue by changing event hook from onAfterInitialise to onAfterRoute
|
||||
|
||||
### Fixed
|
||||
|
||||
- Template chrome (header, footer, modules) no longer loads when accessing TOS page in offline mode
|
||||
- Component-only view now properly applied when site is offline
|
||||
|
||||
### Removed
|
||||
|
||||
- Legacy duplicate manifest at `src/plugins/system/mokojoomtos/mokojoomtos.xml`
|
||||
|
||||
## [1.0.0] - 2026-01-16
|
||||
|
||||
### Added
|
||||
|
||||
- Initial template repository structure
|
||||
- Comprehensive Makefile with 30+ build targets
|
||||
- Complete README.md with usage documentation
|
||||
- CONTRIBUTING.md with contribution guidelines
|
||||
- SECURITY.md with security policy and best practices
|
||||
- CHANGELOG.md for version tracking
|
||||
- LICENSE file (GPL-3.0-or-later)
|
||||
- EditorConfig for consistent code formatting
|
||||
- Documentation structure in `docs/` directory
|
||||
- Placeholders for component directories (`admin/`, `site/`, `media/`)
|
||||
- MokoStandards compliance:
|
||||
- File header standards with copyright and SPDX identifiers
|
||||
- Joomla coding standards configuration
|
||||
- PHPStan static analysis setup
|
||||
- Dependency security auditing
|
||||
- Makefile targets for:
|
||||
- Dependency management (install-deps, update-deps)
|
||||
- Code validation (lint, phpcs, phpstan)
|
||||
- Testing (test, test-coverage)
|
||||
- Building (build, build-assets)
|
||||
- Development (dev-install, watch-assets)
|
||||
- Release management (release, bump-version)
|
||||
- Utility commands (clean, version, help)
|
||||
- Documentation index files with MokoStandards metadata
|
||||
|
||||
### Documentation
|
||||
|
||||
- Comprehensive README with:
|
||||
- Quick start guide
|
||||
- Prerequisites and installation
|
||||
- Usage examples
|
||||
- Project structure overview
|
||||
- Makefile command reference
|
||||
- Standards compliance information
|
||||
- Detailed CONTRIBUTING guide with:
|
||||
- Commit message conventions
|
||||
- DCO sign-off requirements
|
||||
- Development setup instructions
|
||||
- Code review process
|
||||
- Security policy with:
|
||||
- Vulnerability reporting procedures
|
||||
- Security best practices
|
||||
- Code examples for secure development
|
||||
- Template structure for Joomla 4.x and 5.x compatibility
|
||||
|
||||
## Version Guidelines
|
||||
|
||||
### Version Numbering
|
||||
|
||||
This template follows [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **MAJOR** version for incompatible changes (e.g., 2.0.0)
|
||||
- **MINOR** version for new features (e.g., 1.1.0)
|
||||
- **PATCH** version for bug fixes (e.g., 1.0.1)
|
||||
|
||||
### Change Categories
|
||||
|
||||
- **Added**: New features
|
||||
- **Changed**: Changes to existing functionality
|
||||
- **Deprecated**: Soon-to-be removed features
|
||||
- **Removed**: Removed features
|
||||
- **Fixed**: Bug fixes
|
||||
- **Security**: Security fixes
|
||||
|
||||
## Release Notes
|
||||
|
||||
### [1.0.0] Release Notes
|
||||
|
||||
This is the initial release of the MokoJoomTOS plugin. It provides a production-ready Joomla system plugin for offline TOS access with:
|
||||
|
||||
- **Complete build system** via comprehensive Makefile
|
||||
- **MokoStandards compliance** out of the box
|
||||
- **Developer-friendly** workflow with automation
|
||||
- **Security-focused** with built-in best practices
|
||||
- **Well-documented** with clear usage instructions
|
||||
|
||||
This template is ready for use in creating new Joomla components that follow organizational coding standards and best practices.
|
||||
|
||||
### Migration Guide
|
||||
|
||||
**For New Projects**: Simply use this template to create your new component repository.
|
||||
|
||||
**For Existing Projects**: Review the Makefile, documentation structure, and standards compliance files to gradually adopt features that benefit your project.
|
||||
- Initial plugin release
|
||||
- Offline mode bypass for configured menu slug
|
||||
- Auto-provisioning installer (creates article, menu type, menu item)
|
||||
- Component-only rendering (`tmpl=component`)
|
||||
- Language files for en-GB and en-US
|
||||
- MokoStandards compliance
|
||||
|
||||
## Links
|
||||
|
||||
- [Repository](https://github.com/mokoconsulting-tech/MokoJoomTOS)
|
||||
- [Issues](https://github.com/mokoconsulting-tech/MokoJoomTOS/issues)
|
||||
- [Pull Requests](https://github.com/mokoconsulting-tech/MokoJoomTOS/pulls)
|
||||
- [MokoStandards](https://github.com/mokoconsulting-tech/MokoCodingDefaults)
|
||||
- [Repository](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS)
|
||||
- [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/issues)
|
||||
- [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases)
|
||||
|
||||
[Unreleased]: https://github.com/mokoconsulting-tech/MokoJoomTOS/compare/v03.08.04...HEAD
|
||||
[03.08.04]: https://github.com/mokoconsulting-tech/MokoJoomTOS/releases/tag/v03.08.04
|
||||
[1.0.0]: https://github.com/mokoconsulting-tech/MokoJoomTOS/releases/tag/v1.0.0
|
||||
[Unreleased]: https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/compare/stable...dev
|
||||
[04.02.01]: https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/tag/stable
|
||||
[04.01.00]: https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/compare/v04.01.00...stable
|
||||
[04.00.00]: https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/compare/v03.09.00...stable
|
||||
[03.09.00]: https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/tag/v03.09.00
|
||||
[1.0.0]: https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/tag/v1.0.0
|
||||
|
||||
@@ -6,7 +6,7 @@ MokoJoomTOS is a lightweight Joomla 4.x/5.x system plugin that allows Terms of S
|
||||
|
||||
```
|
||||
/
|
||||
├── .github/ # GitHub workflows, issue templates, copilot-instructions.md
|
||||
├── .mokogitea/ # GitHub workflows, issue templates, copilot-instructions.md
|
||||
├── docs/ # Detailed documentation (currently minimal with index.md)
|
||||
├── scripts/ # Build and utility scripts (validate/, package scripts)
|
||||
├── src/ # Plugin source code at root level (NOT nested under plugins/)
|
||||
@@ -75,7 +75,7 @@ XML files use the MokoStandard header format:
|
||||
DEFGROUP: MokoJoomTOS
|
||||
INGROUP: plg_system_mokojoomtos
|
||||
PATH: src/mokojoomtos.xml
|
||||
VERSION: 03.08.04
|
||||
VERSION: 04.00.00
|
||||
BRIEF: [Brief description of file purpose]
|
||||
=========================================================================
|
||||
-->
|
||||
@@ -88,7 +88,7 @@ Markdown files use an HTML comment format with the same structure.
|
||||
- **DEFGROUP**: Top-level group (always `MokoJoomTOS` for this repo)
|
||||
- **INGROUP**: Subgroup/component (always `plg_system_mokojoomtos`)
|
||||
- **PATH**: Relative path from repository root (e.g., `src/mokojoomtos.xml`)
|
||||
- **VERSION**: Current plugin version (currently `03.08.04`)
|
||||
- **VERSION**: Current plugin version (currently `04.00.00`)
|
||||
- **BRIEF**: One-line description of file's purpose
|
||||
|
||||
### Exempt Files
|
||||
@@ -327,7 +327,7 @@ No automated test infrastructure exists in this repository. Manual testing requi
|
||||
```bash
|
||||
# Manual packaging (build scripts being migrated to scripts/ directory)
|
||||
cd src/
|
||||
zip -r ../plg_system_mokojoomtos-03.08.04.zip .
|
||||
zip -r ../plg_system_mokojoomtos-04.00.00.zip .
|
||||
```
|
||||
|
||||
Package should contain: `mokojoomtos.php`, `mokojoomtos.xml`, `script.php`, `src/`, `language/`, `administrator/`
|
||||
@@ -397,7 +397,7 @@ Before opening a pull request, ensure:
|
||||
|
||||
- [ ] CHANGELOG.md updated with changes under correct version
|
||||
- [ ] README.md updated if user-facing changes
|
||||
- [ ] All markdown file headers include VERSION: 03.08.04
|
||||
- [ ] All markdown file headers include VERSION: 04.00.00
|
||||
- [ ] XML file headers include complete FILE INFORMATION block
|
||||
|
||||
## Version Management
|
||||
@@ -469,6 +469,6 @@ This repository follows minimal documentation structure with essential docs in r
|
||||
2. **SECURITY.md** - Security policy and vulnerability reporting procedures
|
||||
3. **CODE_OF_CONDUCT.md** - Community standards and behavior expectations
|
||||
4. **CHANGELOG.md** - Version history following Keep a Changelog format
|
||||
5. **.github/copilot-instructions.md** - Comprehensive guidance for GitHub Copilot (includes all Joomla patterns)
|
||||
5. **.mokogitea/copilot-instructions.md** - Comprehensive guidance for GitHub Copilot (includes all Joomla patterns)
|
||||
|
||||
Currently no `docs/policy/` directory exists - all policy is in root-level markdown files.
|
||||
|
||||
@@ -1,275 +1,204 @@
|
||||
# MokoJoomTOS - Offline Access Plugin for Joomla
|
||||
# MokoJoomTOS
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://www.joomla.org/)
|
||||
[](#enterprise-features-)
|
||||
A Joomla system plugin that keeps your Terms of Service, Privacy Policy, or any legal page accessible to visitors -- even when the site is in offline (maintenance) mode.
|
||||
|
||||
A lightweight Joomla system plugin that allows your Terms of Service (or any other legal document) to remain accessible even when your site is in offline/maintenance mode.
|
||||
    
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Author** | [Moko Consulting](https://mokoconsulting.tech) |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Platform** | [Gitea](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS) |
|
||||
| **Version** | 04.02.00 |
|
||||
|
||||
---
|
||||
|
||||
## Why MokoJoomTOS?
|
||||
|
||||
When you put a Joomla site into offline mode for maintenance, *every* page returns the offline message -- including legal pages that may need to remain publicly accessible. Many jurisdictions require Terms of Service and Privacy Policy pages to be available at all times. MokoJoomTOS solves this by selectively bypassing offline mode for a single configured URL slug, rendering only the article content without any site template chrome.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Simple & Lightweight** - Just a single plugin, no component needed
|
||||
✅ **Native Joomla Integration** - Uses standard Joomla articles and menus
|
||||
✅ **Slug-Based Access** - Configure which menu slug remains accessible when offline
|
||||
✅ **Zero Database Impact** - No custom tables or migrations
|
||||
✅ **Automatic Setup** - Creates article, menu, and configuration automatically
|
||||
✅ **Enterprise Ready** - Secure, scalable, and compliant with best practices
|
||||
✅ **Component View** - Displays TOS without template chrome during offline mode
|
||||
- **Offline-mode bypass** -- Keeps a designated page accessible while the rest of the site shows the offline message
|
||||
- **Component-only rendering** -- Strips headers, footers, navigation, and modules for a minimal, secure view
|
||||
- **Single-parameter configuration** -- Just one setting: the menu item slug to expose
|
||||
- **Child-path matching** -- A slug of `legal` also matches `/legal/privacy`, `/legal/terms`, etc.
|
||||
- **Zero database footprint** -- No custom tables; uses native Joomla content and menu infrastructure
|
||||
- **Auto-provisioning installer** -- On first install, automatically creates a sample article, "Legal" menu type, menu item, and enables the plugin
|
||||
- **Idempotent installation** -- Safe to reinstall; checks for existing resources before creating duplicates
|
||||
- **Built-in update server** -- Joomla automatically checks for new versions via the Gitea-hosted `updates.xml`
|
||||
- **Joomla 4+ namespaced architecture** -- Uses `SubscriberInterface` and proper PSR-4 namespacing
|
||||
- **Multilingual support** -- Language files included for en-GB and en-US (site and admin)
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Install the plugin** - Upload and install the ZIP file
|
||||
2. **Automatic setup** - Plugin creates article and Legal menu automatically
|
||||
3. **Pre-configured** - Terms of Service is ready at `/terms-of-service`
|
||||
4. **Visitors can view** legal documents even during site maintenance
|
||||
The plugin subscribes to the `onAfterRoute` Joomla event. When a request comes in:
|
||||
|
||||
1. **Check scope** -- Only acts on the site application (not admin)
|
||||
2. **Check offline** -- Only acts when the site is in offline mode
|
||||
3. **Match slug** -- Compares the URI path against the configured `tos_slug` parameter
|
||||
4. **Bypass offline** -- If matched, temporarily sets `offline = 0` for this request only (not persisted to database)
|
||||
5. **Strip template** -- Forces `tmpl=component` so only the article content renders (no header, footer, or modules)
|
||||
|
||||
If the URL does not match, the plugin does nothing and visitors see the normal offline page.
|
||||
|
||||
```
|
||||
Visitor requests: /terms-of-service
|
||||
|
|
||||
v
|
||||
Joomla routing resolves URL
|
||||
|
|
||||
v
|
||||
onAfterRoute fires
|
||||
|
|
||||
v
|
||||
Plugin checks: Is offline mode enabled?
|
||||
| No --> return (do nothing)
|
||||
| Yes v
|
||||
Plugin compares: URI path vs configured slug
|
||||
| No match --> return (show offline page)
|
||||
| Match v
|
||||
Plugin sets: $config->set('offline', 0) <-- temporary, this request only
|
||||
Plugin sets: tmpl=component <-- no template chrome
|
||||
|
|
||||
v
|
||||
Joomla renders article content only
|
||||
```
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
+-- mokojoomtos.php # Legacy entry point (loads namespace)
|
||||
+-- mokojoomtos.xml # Plugin manifest (params, files, update server)
|
||||
+-- script.php # Installation script (auto-creates article + menu)
|
||||
+-- src/
|
||||
| +-- Extension/
|
||||
| | +-- MokoJoomTOS.php # Main plugin class (event handler)
|
||||
| +-- Field/
|
||||
| +-- MenuslugField.php # Custom form field for slug selection
|
||||
+-- language/ # Site-side translations (en-GB, en-US)
|
||||
+-- administrator/
|
||||
+-- language/ # Admin-side translations (en-GB, en-US)
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### From Release (Recommended)
|
||||
|
||||
1. Download the latest `plg_system_mokojoomtos-x.x.x.zip` from [Releases](https://github.com/mokoconsulting-tech/MokoJoomTOS/releases)
|
||||
2. In Joomla admin, go to **System → Install → Extensions**
|
||||
1. Download the latest `plg_system_mokojoomtos-x.x.x.zip` from the [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases) page
|
||||
2. In Joomla admin, go to **System > Install > Extensions**
|
||||
3. Upload the ZIP file
|
||||
4. **That's it!** The plugin automatically:
|
||||
- ✅ Creates a Terms of Service article
|
||||
- ✅ Creates a "Legal" menu type
|
||||
- ✅ Creates a menu item with slug `terms-of-service`
|
||||
- ✅ Enables itself
|
||||
- ✅ Configures the slug automatically
|
||||
4. Done -- the plugin automatically:
|
||||
- Creates a Terms of Service article with sample content
|
||||
- Creates a "Legal" menu type
|
||||
- Creates a menu item with alias `terms-of-service`
|
||||
- Enables itself
|
||||
- Configures the slug parameter
|
||||
|
||||
No manual configuration is needed after installation.
|
||||
|
||||
### Build from Source
|
||||
|
||||
Build scripts are being migrated to the [scripts](./scripts/) directory. For now, you can manually package the plugin:
|
||||
```bash
|
||||
git clone https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS.git
|
||||
cd MokoJoomTOS/src
|
||||
zip -r ../plg_system_mokojoomtos.zip \
|
||||
mokojoomtos.php \
|
||||
mokojoomtos.xml \
|
||||
script.php \
|
||||
src/ \
|
||||
language/ \
|
||||
administrator/
|
||||
```
|
||||
|
||||
1. Copy files from `src/` to a temporary directory
|
||||
2. Create a ZIP archive of the contents
|
||||
3. The ZIP should contain: `mokojoomtos.php`, `mokojoomtos.xml`, `script.php`, `src/`, `language/`, and `administrator/`
|
||||
|
||||
Alternatively, download pre-built releases from [Releases](https://github.com/mokoconsulting-tech/MokoJoomTOS/releases).
|
||||
|
||||
## Automatic vs Manual Setup
|
||||
|
||||
### 🚀 Automatic Setup (Default - Recommended for Enterprise)
|
||||
|
||||
The plugin automatically creates everything during installation:
|
||||
- ✅ Terms of Service article with sample content
|
||||
- ✅ "Legal" menu type for organization
|
||||
- ✅ Menu item with alias `terms-of-service`
|
||||
- ✅ Plugin enabled and configured
|
||||
|
||||
**No manual steps required!**
|
||||
|
||||
### 🔧 Manual Setup (Advanced Users)
|
||||
|
||||
If you prefer to create your own content:
|
||||
|
||||
#### Step 1: Create Your Terms Article
|
||||
|
||||
- Go to **Content → Articles → New**
|
||||
- Title: "Terms of Service"
|
||||
- Add your terms content
|
||||
- Save
|
||||
|
||||
#### Step 2: Create Menu Item
|
||||
|
||||
- Go to **Menus → Legal → Add New Menu Item**
|
||||
- Menu Item Type: Single Article
|
||||
- Select your Terms article
|
||||
- Menu Title: "Terms of Service"
|
||||
- **Alias**: `terms-of-service` (this is the slug!)
|
||||
- Save
|
||||
|
||||
#### Step 3: Configure Plugin
|
||||
|
||||
- Go to **System → Plugins → MokoJoomTOS**
|
||||
- **Terms of Service Menu Slug**: `terms-of-service`
|
||||
- **Status**: Enabled
|
||||
- Save
|
||||
|
||||
### Testing
|
||||
|
||||
- Set site offline: **System → Global Configuration → Site Offline = Yes**
|
||||
- Visit `yoursite.com/terms-of-service` - accessible! ✅
|
||||
- Visit other pages - offline message appears ✅
|
||||
Then install the resulting ZIP through Joomla admin.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin has just ONE configuration field:
|
||||
Access the plugin settings at **System > Plugins > MokoJoomTOS** (search for "mokojoomtos"):
|
||||
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| **Terms of Service Menu Slug** | The menu item alias that should remain accessible when site is offline | `terms-of-service` |
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| **Terms of Service Menu Slug** (`tos_slug`) | Custom `menuslug` field | `terms-of-service` | The menu item alias that should remain accessible when the site is offline |
|
||||
|
||||
## Technical Details
|
||||
### Changing the Slug
|
||||
|
||||
### Plugin Specs
|
||||
To protect a different page (e.g., a Privacy Policy):
|
||||
|
||||
- **Type**: System Plugin
|
||||
- **Event**: `onAfterRoute`
|
||||
- **Compatibility**: Joomla 4.x, 5.x
|
||||
- **PHP**: 7.4+
|
||||
- **Size**: ~6.4 KB
|
||||
- **Files**: 7
|
||||
1. Create your article in **Content > Articles**
|
||||
2. Create a menu item with your desired alias (e.g., `privacy-policy`)
|
||||
3. Go to **System > Plugins > MokoJoomTOS**
|
||||
4. Change the slug to match your menu item alias
|
||||
5. Save
|
||||
|
||||
### How It Works Internally
|
||||
Child paths are also matched -- if the slug is `legal`, then `/legal/privacy` and `/legal/terms` would also be accessible.
|
||||
|
||||
```
|
||||
User requests: /terms-of-service
|
||||
↓
|
||||
Plugin checks: Is site offline?
|
||||
↓ YES
|
||||
Plugin checks: Does URL match configured slug?
|
||||
↓ YES
|
||||
Plugin sets: offline = 0 (temporarily, for this request only)
|
||||
Plugin sets: tmpl = component (no template chrome)
|
||||
↓
|
||||
Joomla displays article content only
|
||||
```
|
||||
### Limitations
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
MokoJoomTOS/
|
||||
├── src/ # Plugin source files
|
||||
│ ├── mokojoomtos.php # Plugin entry point
|
||||
│ ├── mokojoomtos.xml # Plugin manifest
|
||||
│ ├── script.php # Installation script
|
||||
│ ├── src/
|
||||
│ │ ├── Extension/ # Modern namespaced plugin class
|
||||
│ │ │ └── MokoJoomTOS.php
|
||||
│ │ └── Field/ # Custom form fields
|
||||
│ │ └── MenuslugField.php
|
||||
│ ├── language/ # Site language files
|
||||
│ │ ├── en-GB/
|
||||
│ │ └── en-US/
|
||||
│ └── administrator/ # Admin language files
|
||||
│ └── language/
|
||||
│ ├── en-GB/
|
||||
│ └── en-US/
|
||||
├── docs/ # Documentation
|
||||
├── scripts/ # Build and utility scripts
|
||||
├── README.md # This file
|
||||
├── LICENSE # GPL-3.0 license
|
||||
└── update.xml # Update server configuration
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Legal Requirement** - Display Terms of Service during site maintenance
|
||||
- **Privacy Policy** - Keep privacy policy accessible at all times
|
||||
- **Legal Notices** - Regulatory compliance documentation
|
||||
- **Contact Information** - Emergency contact during extended downtime
|
||||
- **Accessibility Statement** - Maintain accessibility information
|
||||
|
||||
## Enterprise Features 🏢
|
||||
|
||||
### Automatic Deployment
|
||||
- **Zero-touch installation**: Installs and configures automatically
|
||||
- **Idempotent setup**: Safe to run multiple times, checks for existing resources
|
||||
- **Auto-enable**: Plugin activates itself after installation
|
||||
- **Legal menu organization**: Creates dedicated "Legal" menu type for better structure
|
||||
|
||||
### Security & Compliance
|
||||
- **Component-only view**: Displays TOS without template chrome during offline mode (minimizes attack surface)
|
||||
- **SQL injection prevention**: Uses Joomla's query builder with proper escaping
|
||||
- **Input sanitization**: Overwrites malicious query parameters
|
||||
- **Access control ready**: Supports Joomla ACL
|
||||
- **GPL-3.0 licensed**: Enterprise-friendly open source license
|
||||
|
||||
### Performance & Scalability
|
||||
- **Lightweight**: ~9KB zipped package
|
||||
- **Efficient event handling**: Minimal overhead, early exit patterns
|
||||
- **No database impact**: Uses native Joomla tables only
|
||||
- **High availability**: No external dependencies
|
||||
|
||||
### Monitoring & Support
|
||||
- **Error logging**: Uses Joomla's Log class
|
||||
- **Clear feedback**: Installation success messages and status indicators
|
||||
- **Update server**: Configured for automatic update notifications
|
||||
- **Professional support**: Available from Moko Consulting
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I use it for Privacy Policy too?**
|
||||
A: The plugin supports one slug currently. You can create one article with both documents, or list both under a parent "legal" menu.
|
||||
|
||||
**Q: Does it work with SEF URLs?**
|
||||
A: Yes! Works perfectly with Joomla's SEF (Search Engine Friendly) URLs.
|
||||
|
||||
**Q: Can I use a different slug?**
|
||||
A: Absolutely! Any slug works - just match it with your menu item alias.
|
||||
|
||||
**Q: Will it conflict with other plugins?**
|
||||
A: No, it's minimal and only acts during offline mode for the specific slug.
|
||||
|
||||
**Q: Does it store content?**
|
||||
A: No! It uses your existing Joomla articles. No database tables needed.
|
||||
- Only one slug can be configured at a time
|
||||
- To expose multiple legal pages, either combine them into a single article or use a parent slug (e.g., `legal`) with all legal menu items as children
|
||||
|
||||
## Requirements
|
||||
|
||||
- Joomla 4.0 or later
|
||||
- PHP 7.4 or later
|
||||
| Requirement | Minimum |
|
||||
|---|---|
|
||||
| **Joomla** | 5.0 or later |
|
||||
| **PHP** | 8.1 or later |
|
||||
| **Database** | None required (uses native Joomla tables) |
|
||||
|
||||
## Development
|
||||
## Update Server
|
||||
|
||||
### Building
|
||||
The plugin includes an update server configuration. Joomla will automatically check for new versions:
|
||||
|
||||
Build scripts are being migrated to the [scripts](./scripts/) directory. For now, you can manually package the plugin:
|
||||
- **Primary**: `https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/raw/branch/main/updates.xml`
|
||||
- **Mirror**: `https://raw.githubusercontent.com/mokoconsulting-tech/MokoJoomTOS/main/updates.xml`
|
||||
|
||||
1. Copy files from `src/` to a temporary directory
|
||||
2. Create a ZIP archive of the contents
|
||||
3. Name it `plg_system_mokojoomtos-{version}.zip`
|
||||
Updates can be applied through **System > Update > Extensions** in the Joomla admin.
|
||||
|
||||
The installable ZIP should contain: `mokojoomtos.php`, `mokojoomtos.xml`, `script.php`, `src/`, `language/`, and `administrator/` directories.
|
||||
## Verify Installation
|
||||
|
||||
Alternatively, download pre-built releases from [Releases](https://github.com/mokoconsulting-tech/MokoJoomTOS/releases).
|
||||
1. Set your site offline: **System > Global Configuration > Site Offline = Yes**
|
||||
2. Open an incognito/private browser window
|
||||
3. Visit `yoursite.com/terms-of-service`
|
||||
4. The Terms of Service article should display (without full site template)
|
||||
5. Visit any other page -- the offline message should appear
|
||||
|
||||
### Testing
|
||||
## Uninstallation
|
||||
|
||||
1. Build the plugin using the method above
|
||||
2. Install in Joomla test instance
|
||||
3. Create test article and menu item with alias
|
||||
4. Configure plugin with that alias
|
||||
5. Set site offline in Global Configuration
|
||||
6. Test accessing the configured slug URL
|
||||
1. Go to **System > Manage > Extensions**
|
||||
2. Search for "mokojoomtos"
|
||||
3. Select the plugin and click **Uninstall**
|
||||
|
||||
## License
|
||||
Note: Uninstalling the plugin does not remove the article or menu item it created. Remove those manually if desired.
|
||||
|
||||
GNU General Public License v3.0 or later
|
||||
## Security
|
||||
|
||||
See [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Author
|
||||
|
||||
**Moko Consulting**
|
||||
|
||||
- Website: [https://mokoconsulting.tech](https://mokoconsulting.tech)
|
||||
- Email: hello@mokoconsulting.tech
|
||||
- **JEXEC check**: All PHP files verify `defined('_JEXEC') or die` to prevent direct access
|
||||
- **Input handling**: The plugin overwrites query parameters rather than reading user input
|
||||
- **SQL safety**: The installation script uses Joomla's query builder with proper quoting
|
||||
- **Minimal scope**: The plugin only acts when the site is offline and the URL matches -- zero overhead in normal operation
|
||||
- **No custom tables**: Zero database footprint beyond the standard Joomla extension registration
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please submit Pull Requests.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines and contribution instructions.
|
||||
|
||||
## Support
|
||||
## Documentation
|
||||
|
||||
File issues at: [GitHub Issues](https://github.com/mokoconsulting-tech/MokoJoomTOS/issues)
|
||||
Full documentation is available on the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/wiki):
|
||||
|
||||
## Changelog
|
||||
| Page | Description |
|
||||
|---|---|
|
||||
| [Installation](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/wiki/Installation) | Step-by-step installation guide |
|
||||
| [Configuration](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/wiki/Configuration) | Plugin parameters and slug setup |
|
||||
| [How It Works](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/wiki/How-It-Works.-) | Technical architecture and event flow |
|
||||
| [Update Server](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/wiki/update-server.-.-) | How `updates.xml` is automatically managed |
|
||||
|
||||
### Version 03.08.04 (2026-02-28)
|
||||
## License
|
||||
|
||||
- ✅ Fixed template chrome loading issue
|
||||
- ✅ Component-only view now properly applied in offline mode
|
||||
- ✅ Event hook changed from onAfterInitialise to onAfterRoute
|
||||
|
||||
### Version 1.0.0 (2026-02-27)
|
||||
|
||||
- ✅ Initial release
|
||||
- ✅ Slug-based offline access
|
||||
- ✅ Single configuration field
|
||||
- ✅ Multi-language support (en-GB, en-US)
|
||||
- ✅ Joomla 4.x and 5.x compatibility
|
||||
This project is licensed under the GNU General Public License v3.0 or later -- see the [LICENSE](LICENSE) file.
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ by Moko Consulting**
|
||||
*[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)*
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# TODO
|
||||
|
||||
> **Note:** This file is not tracked in version control (.gitignore). It is for local task tracking only.
|
||||
|
||||
## Critical
|
||||
-
|
||||
|
||||
## Normal
|
||||
-
|
||||
|
||||
## Low
|
||||
-
|
||||
@@ -1,16 +0,0 @@
|
||||
# Docs Index: /templates/repos/joomla/component/docs
|
||||
|
||||
## Purpose
|
||||
|
||||
This index provides navigation to documentation within this folder.
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Document Type:** index
|
||||
- **Auto-generated:** This file is automatically generated by rebuild_indexes.py
|
||||
|
||||
## Revision History
|
||||
|
||||
| Change | Notes | Author |
|
||||
| --- | --- | --- |
|
||||
| Automated update | Generated by documentation index automation | rebuild_indexes.py |
|
||||
@@ -1,119 +0,0 @@
|
||||
<!--
|
||||
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: MokoJoomTOS.Documentation
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoJoomTOS
|
||||
PATH: /docs/update-server.md
|
||||
VERSION: 04.04.00
|
||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||
-->
|
||||
|
||||
# Joomla Update Server
|
||||
|
||||
[](https://github.com/mokoconsulting-tech/MokoStandards)
|
||||
|
||||
This document explains how `update.xml` is automatically managed for this Joomla extension following the [Joomla Update Server specification](https://docs.joomla.org/Deploying_an_Update_Server).
|
||||
|
||||
## How It Works
|
||||
|
||||
Joomla checks for extension updates by fetching an XML file from the URL defined in the `<updateservers>` tag in the extension's XML manifest. MokoStandards generates this file automatically.
|
||||
|
||||
### Automatic Generation
|
||||
|
||||
| Event | Workflow | `<tag>` | `<version>` |
|
||||
|-------|----------|---------|-------------|
|
||||
| Merge to `main` | `auto-release.yml` | `stable` | `XX.YY.ZZ` |
|
||||
| Push to `dev/**` | `deploy-dev.yml` | `development` | `development` |
|
||||
| Push to `rc/**` | `deploy-dev.yml` | `rc` | `XX.YY.ZZ-rc` |
|
||||
|
||||
### Generated XML Structure
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<updates>
|
||||
<update>
|
||||
<name>Extension Name</name>
|
||||
<description>Extension Name update</description>
|
||||
<element>com_extensionname</element>
|
||||
<type>component</type>
|
||||
<version>01.02.03</version>
|
||||
<client>site</client>
|
||||
<folder>system</folder> <!-- plugins only -->
|
||||
<tags>
|
||||
<tag>stable</tag>
|
||||
</tags>
|
||||
<infourl title="Extension Name">https://github.com/.../releases/tag/v01.02.03</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://github.com/.../releases/download/v01.02.03/com_ext-01.02.03.zip</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="((5\.[0-9])|(6\.[0-9]))" />
|
||||
<php_minimum>8.2</php_minimum> <!-- if present in manifest -->
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
</update>
|
||||
</updates>
|
||||
```
|
||||
|
||||
### Metadata Source
|
||||
|
||||
All metadata is extracted from the extension's XML manifest (`src/*.xml`) at build time:
|
||||
|
||||
| XML Element | Source | Notes |
|
||||
|-------------|--------|-------|
|
||||
| `<name>` | `<name>` in manifest | Extension display name |
|
||||
| `<element>` | `<element>` in manifest | Must match installed extension identifier |
|
||||
| `<type>` | `type` attribute on `<extension>` | `component`, `module`, `plugin`, `library`, `package`, `template` |
|
||||
| `<client>` | `client` attribute on `<extension>` | `site` or `administrator` — **required for plugins and modules** |
|
||||
| `<folder>` | `group` attribute on `<extension>` | Plugin group (e.g., `system`, `content`) — **required for plugins** |
|
||||
| `<targetplatform>` | `<targetplatform>` in manifest | Falls back to Joomla 5.x / 6.x if not specified |
|
||||
| `<php_minimum>` | `<php_minimum>` in manifest | Included only if present |
|
||||
|
||||
### Extension Manifest Setup
|
||||
|
||||
Your XML manifest must include an `<updateservers>` tag pointing to the `update.xml` on the `main` branch:
|
||||
|
||||
```xml
|
||||
<extension type="component" client="site" method="upgrade">
|
||||
<name>My Extension</name>
|
||||
<element>com_myextension</element>
|
||||
<!-- ... -->
|
||||
<updateservers>
|
||||
<server type="extension" name="My Extension Updates">
|
||||
https://raw.githubusercontent.com/mokoconsulting-tech/MokoJoomTOS/main/update.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
</extension>
|
||||
```
|
||||
|
||||
### Branch Lifecycle
|
||||
|
||||
```
|
||||
dev/XX.YY.ZZ → rc/XX.YY.ZZ → main → version/XX.YY
|
||||
(development) (rc) (stable) (frozen snapshot)
|
||||
```
|
||||
|
||||
1. **Development** (`dev/**`): `update.xml` with `<tag>development</tag>`, download points to branch archive
|
||||
2. **Release Candidate** (`rc/**`): `update.xml` with `<tag>rc</tag>`, version set to `XX.YY.ZZ-rc`
|
||||
3. **Stable Release** (merge to `main`): `update.xml` with `<tag>stable</tag>`, download points to GitHub Release asset
|
||||
4. **Frozen Snapshot** (`version/XX.YY`): immutable, never force-pushed
|
||||
|
||||
### Health Checks
|
||||
|
||||
The `repo_health.yml` workflow verifies on every commit:
|
||||
|
||||
- `update.xml` exists in the repository root
|
||||
- XML manifest exists with `<extension>` tag
|
||||
- `<version>`, `<name>`, `<author>`, `<namespace>` tags present
|
||||
- Extension `type` attribute is valid
|
||||
- Language `.ini` files exist
|
||||
- `index.html` directory listing protection in `src/`, `src/admin/`, `src/site/`
|
||||
|
||||
---
|
||||
|
||||
*Managed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). See [docs/workflows/update-server.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/workflows/update-server.md) for the full specification.*
|
||||
@@ -3,17 +3,26 @@
|
||||
; License GNU General Public License version 3 or later; see LICENSE
|
||||
; Note: All ini files need to be saved as UTF-8
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Offline Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Moko Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_XML_DESCRIPTION="Allows Terms of Service to be accessible via menu slug when the site is in offline mode. Simply configure the menu slug (e.g., 'terms-of-service') and that page will remain accessible even when the site is offline."
|
||||
|
||||
; Configuration
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELDSET_BASIC="Basic Settings"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL="Terms of Service Menu Slug"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC="Enter the menu slug for your Terms of Service page (e.g., 'terms-of-service'). This page will be accessible even when the site is offline. The slug must match the menu item alias exactly."
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL="Offline-Accessible Menu Items"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC="Select one or more menu items that should remain accessible when the site is in offline mode. Hold Ctrl/Cmd to select multiple items."
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_LABEL="Include Child Menu Items"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_DESC="When enabled, child menu items under the selected items will also be accessible during offline mode. For example, selecting 'legal' will also allow access to 'legal/terms-of-service' and 'legal/privacy-policy'."
|
||||
|
||||
; Help
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_LABEL="How to Use This Plugin"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_DESC="<strong>Step 1:</strong> Create a Joomla article for your Terms of Service.<br/><strong>Step 2:</strong> Create a menu item pointing to that article.<br/><strong>Step 3:</strong> Set the menu item alias/slug (e.g., 'terms-of-service').<br/><strong>Step 4:</strong> Enter that same slug above.<br/><strong>Step 5:</strong> When your site goes offline, visitors can still access your-site.com/terms-of-service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_DESC="<strong>Step 1:</strong> Create articles for your legal pages (Terms of Service, Privacy Policy, etc.).<br/><strong>Step 2:</strong> Create menu items pointing to those articles.<br/><strong>Step 3:</strong> Select the menu items above (hold Ctrl/Cmd to select multiple).<br/><strong>Step 4:</strong> When your site goes offline, visitors can still access the selected pages.<br/><br/><em>Tip:</em> The dropdown shows the full URL path for each menu item (e.g., /legal/terms-of-service)."
|
||||
|
||||
; Warnings
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_SEF_WARNING="⚠ SEF URLs are disabled — path matching requires SEF. Itemid fallback is active."
|
||||
|
||||
; Errors
|
||||
PLG_SYSTEM_MOKOJOOMTOS_ERROR_LOADING_MENU_ITEMS="Error loading menu items: %s"
|
||||
|
||||
; Installation messages
|
||||
PLG_SYSTEM_MOKOJOOMTOS_INSTALL_SUCCESS="MokoJoomTOS Plugin installed successfully!"
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
; License GNU General Public License version 3 or later; see LICENSE
|
||||
; Note: All ini files need to be saved as UTF-8
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Offline Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Moko Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_XML_DESCRIPTION="Allows Terms of Service to be accessible via menu slug when site is offline"
|
||||
|
||||
@@ -3,17 +3,26 @@
|
||||
; License GNU General Public License version 3 or later; see LICENSE
|
||||
; Note: All ini files need to be saved as UTF-8
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Offline Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Moko Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_XML_DESCRIPTION="Allows Terms of Service to be accessible via menu slug when the site is in offline mode. Simply configure the menu slug (e.g., 'terms-of-service') and that page will remain accessible even when the site is offline."
|
||||
|
||||
; Configuration
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELDSET_BASIC="Basic Settings"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL="Terms of Service Menu Slug"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC="Enter the menu slug for your Terms of Service page (e.g., 'terms-of-service'). This page will be accessible even when the site is offline. The slug must match the menu item alias exactly."
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL="Offline-Accessible Menu Items"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC="Select one or more menu items that should remain accessible when the site is in offline mode. Hold Ctrl/Cmd to select multiple items."
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_LABEL="Include Child Menu Items"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_DESC="When enabled, child menu items under the selected items will also be accessible during offline mode. For example, selecting 'legal' will also allow access to 'legal/terms-of-service' and 'legal/privacy-policy'."
|
||||
|
||||
; Help
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_LABEL="How to Use This Plugin"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_DESC="<strong>Step 1:</strong> Create a Joomla article for your Terms of Service.<br/><strong>Step 2:</strong> Create a menu item pointing to that article.<br/><strong>Step 3:</strong> Set the menu item alias/slug (e.g., 'terms-of-service').<br/><strong>Step 4:</strong> Enter that same slug above.<br/><strong>Step 5:</strong> When your site goes offline, visitors can still access your-site.com/terms-of-service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_DESC="<strong>Step 1:</strong> Create articles for your legal pages (Terms of Service, Privacy Policy, etc.).<br/><strong>Step 2:</strong> Create menu items pointing to those articles.<br/><strong>Step 3:</strong> Select the menu items above (hold Ctrl/Cmd to select multiple).<br/><strong>Step 4:</strong> When your site goes offline, visitors can still access the selected pages.<br/><br/><em>Tip:</em> The dropdown shows the full URL path for each menu item (e.g., /legal/terms-of-service)."
|
||||
|
||||
; Warnings
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_SEF_WARNING="⚠ SEF URLs are disabled — path matching requires SEF. Itemid fallback is active."
|
||||
|
||||
; Errors
|
||||
PLG_SYSTEM_MOKOJOOMTOS_ERROR_LOADING_MENU_ITEMS="Error loading menu items: %s"
|
||||
|
||||
; Installation messages
|
||||
PLG_SYSTEM_MOKOJOOMTOS_INSTALL_SUCCESS="MokoJoomTOS Plugin installed successfully!"
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
; License GNU General Public License version 3 or later; see LICENSE
|
||||
; Note: All ini files need to be saved as UTF-8
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Offline Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Moko Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_XML_DESCRIPTION="Allows Terms of Service to be accessible via menu slug when site is offline"
|
||||
|
||||
@@ -3,17 +3,26 @@
|
||||
; License GNU General Public License version 3 or later; see LICENSE
|
||||
; Note: All ini files need to be saved as UTF-8
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Offline Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Moko Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_XML_DESCRIPTION="Allows Terms of Service to be accessible via menu slug when the site is in offline mode. Simply configure the menu slug (e.g., 'terms-of-service') and that page will remain accessible even when the site is offline."
|
||||
|
||||
; Configuration
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELDSET_BASIC="Basic Settings"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL="Terms of Service Menu Slug"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC="Enter the menu slug for your Terms of Service page (e.g., 'terms-of-service'). This page will be accessible even when the site is offline. The slug must match the menu item alias exactly."
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL="Offline-Accessible Menu Items"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC="Select one or more menu items that should remain accessible when the site is in offline mode. Hold Ctrl/Cmd to select multiple items."
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_LABEL="Include Child Menu Items"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_DESC="When enabled, child menu items under the selected items will also be accessible during offline mode. For example, selecting 'legal' will also allow access to 'legal/terms-of-service' and 'legal/privacy-policy'."
|
||||
|
||||
; Warnings
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_SEF_WARNING="⚠ SEF URLs are disabled — path matching requires SEF. Itemid fallback is active."
|
||||
|
||||
; Help
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_LABEL="How to Use This Plugin"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_DESC="<strong>Step 1:</strong> Create a Joomla article for your Terms of Service.<br/><strong>Step 2:</strong> Create a menu item pointing to that article.<br/><strong>Step 3:</strong> Set the menu item alias/slug (e.g., 'terms-of-service').<br/><strong>Step 4:</strong> Enter that same slug above.<br/><strong>Step 5:</strong> When your site goes offline, visitors can still access yoursite.com/terms-of-service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_DESC="<strong>Step 1:</strong> Create articles for your legal pages (Terms of Service, Privacy Policy, etc.).<br/><strong>Step 2:</strong> Create menu items pointing to those articles.<br/><strong>Step 3:</strong> Select the menu items above (hold Ctrl/Cmd to select multiple).<br/><strong>Step 4:</strong> When your site goes offline, visitors can still access the selected pages.<br/><br/><em>Tip:</em> The dropdown shows the full URL path for each menu item (e.g., /legal/terms-of-service)."
|
||||
|
||||
; Errors
|
||||
PLG_SYSTEM_MOKOJOOMTOS_ERROR_LOADING_MENU_ITEMS="Error loading menu items: %s"
|
||||
|
||||
; Installation messages
|
||||
PLG_SYSTEM_MOKOJOOMTOS_INSTALL_SUCCESS="MokoJoomTOS Plugin installed successfully!"
|
||||
|
||||
@@ -3,17 +3,26 @@
|
||||
; License GNU General Public License version 3 or later; see LICENSE
|
||||
; Note: All ini files need to be saved as UTF-8
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Offline Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS="System - Moko Terms of Service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_XML_DESCRIPTION="Allows Terms of Service to be accessible via menu slug when the site is in offline mode. Simply configure the menu slug (e.g., 'terms-of-service') and that page will remain accessible even when the site is offline."
|
||||
|
||||
; Configuration
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELDSET_BASIC="Basic Settings"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL="Terms of Service Menu Slug"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC="Enter the menu slug for your Terms of Service page (e.g., 'terms-of-service'). This page will be accessible even when the site is offline. The slug must match the menu item alias exactly."
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL="Offline-Accessible Menu Items"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC="Select one or more menu items that should remain accessible when the site is in offline mode. Hold Ctrl/Cmd to select multiple items."
|
||||
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_LABEL="Include Child Menu Items"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_DESC="When enabled, child menu items under the selected items will also be accessible during offline mode. For example, selecting 'legal' will also allow access to 'legal/terms-of-service' and 'legal/privacy-policy'."
|
||||
|
||||
; Warnings
|
||||
PLG_SYSTEM_MOKOJOOMTOS_FIELD_SEF_WARNING="⚠ SEF URLs are disabled — path matching requires SEF. Itemid fallback is active."
|
||||
|
||||
; Help
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_LABEL="How to Use This Plugin"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_DESC="<strong>Step 1:</strong> Create a Joomla article for your Terms of Service.<br/><strong>Step 2:</strong> Create a menu item pointing to that article.<br/><strong>Step 3:</strong> Set the menu item alias/slug (e.g., 'terms-of-service').<br/><strong>Step 4:</strong> Enter that same slug above.<br/><strong>Step 5:</strong> When your site goes offline, visitors can still access yoursite.com/terms-of-service"
|
||||
PLG_SYSTEM_MOKOJOOMTOS_HELP_DESC="<strong>Step 1:</strong> Create articles for your legal pages (Terms of Service, Privacy Policy, etc.).<br/><strong>Step 2:</strong> Create menu items pointing to those articles.<br/><strong>Step 3:</strong> Select the menu items above (hold Ctrl/Cmd to select multiple).<br/><strong>Step 4:</strong> When your site goes offline, visitors can still access the selected pages.<br/><br/><em>Tip:</em> The dropdown shows the full URL path for each menu item (e.g., /legal/terms-of-service)."
|
||||
|
||||
; Errors
|
||||
PLG_SYSTEM_MOKOJOOMTOS_ERROR_LOADING_MENU_ITEMS="Error loading menu items: %s"
|
||||
|
||||
; Installation messages
|
||||
PLG_SYSTEM_MOKOJOOMTOS_INSTALL_SUCCESS="MokoJoomTOS Plugin installed successfully!"
|
||||
|
||||
+4
-85
@@ -8,98 +8,17 @@
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/**
|
||||
* MokoJoomTOS Offline Mode Bypass Plugin
|
||||
* MokoJoomTOS Legacy Entry Point
|
||||
*
|
||||
* Allows Terms of Service menu to be accessible via slug when the site
|
||||
* is in offline mode.
|
||||
* This file is required by Joomla's plugin loader (<filename plugin="mokojoomtos">)
|
||||
* but is NOT executed under Joomla 5's DI container. The actual plugin logic lives
|
||||
* in src/Extension/MokoJoomTOS.php, bootstrapped via services/provider.php.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class PlgSystemMokojoomtos extends CMSPlugin
|
||||
{
|
||||
/**
|
||||
* Load the language file on instantiation.
|
||||
*
|
||||
* @var boolean
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
/**
|
||||
* Application object
|
||||
*
|
||||
* @var \Joomla\CMS\Application\CMSApplication
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $app;
|
||||
|
||||
/**
|
||||
* After route event handler
|
||||
*
|
||||
* Checks if the current request is for the Terms of Service slug and if
|
||||
* the site is in offline mode. If both conditions are met, temporarily
|
||||
* disables offline mode and sets component-only view for this request.
|
||||
*
|
||||
* This event fires after routing but before template selection, making it
|
||||
* the correct place to set tmpl=component to prevent template chrome loading.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 03.08.04
|
||||
*/
|
||||
public function onAfterRoute()
|
||||
{
|
||||
// Only process for site application
|
||||
if (!$this->app->isClient('site'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the global configuration
|
||||
$config = $this->app->getConfig();
|
||||
|
||||
// Only proceed if site is offline
|
||||
if (!$config->get('offline'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the configured Terms of Service slug
|
||||
$tosSlug = trim($this->params->get('tos_slug', 'terms-of-service'));
|
||||
|
||||
if (empty($tosSlug))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current URI path
|
||||
$uri = Uri::getInstance();
|
||||
$path = trim($uri->getPath(), '/');
|
||||
|
||||
// Remove the base path if present
|
||||
$base = trim(Uri::base(true), '/');
|
||||
if (!empty($base) && strpos($path, $base) === 0)
|
||||
{
|
||||
$path = trim(substr($path, strlen($base)), '/');
|
||||
}
|
||||
|
||||
// Check if the path matches the Terms of Service slug
|
||||
if ($path === $tosSlug || strpos($path, $tosSlug . '/') === 0)
|
||||
{
|
||||
// Temporarily disable offline mode for this request
|
||||
$config->set('offline', 0);
|
||||
|
||||
// Set component-only view (no template chrome)
|
||||
$input = $this->app->input;
|
||||
$input->set('tmpl', 'component');
|
||||
|
||||
// Also set in GET superglobal to ensure recognition
|
||||
$_GET['tmpl'] = 'component';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-8
@@ -25,20 +25,20 @@
|
||||
DEFGROUP: MokoJoomTOS
|
||||
INGROUP: plg_system_mokojoomtos
|
||||
PATH: src/mokojoomtos.xml
|
||||
VERSION: 03.08.04
|
||||
VERSION: 04.02.01
|
||||
BRIEF: Plugin manifest XML file for MokoJoomTOS system plugin
|
||||
=========================================================================
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>plg_system_mokojoomtos</name>
|
||||
<name>System - Moko Terms of Service</name>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-01-01</creationDate>
|
||||
<creationDate>2026-05-16</creationDate>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>03.08.04</version>
|
||||
<description>PLG_SYSTEM_MOKOJOOMTOS_XML_DESCRIPTION</description>
|
||||
<version>04.03.00</version>
|
||||
<description>Allows Terms of Service to be accessible via menu slug when site is offline</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\System\MokoJoomTOS</namespace>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<files>
|
||||
<filename plugin="mokojoomtos">mokojoomtos.php</filename>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
<folder>administrator</folder>
|
||||
</files>
|
||||
@@ -68,9 +69,20 @@
|
||||
type="menuslug"
|
||||
label="PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL"
|
||||
description="PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC"
|
||||
default="terms-of-service"
|
||||
required="true"
|
||||
multiple="true"
|
||||
/>
|
||||
|
||||
<field
|
||||
name="include_children"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_LABEL"
|
||||
description="PLG_SYSTEM_MOKOJOOMTOS_FIELD_INCLUDE_CHILDREN_DESC"
|
||||
default="1"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field
|
||||
name="help_spacer"
|
||||
@@ -84,6 +96,6 @@
|
||||
</config>
|
||||
|
||||
<updateservers>
|
||||
<server type="extension" name="MokoJoomTOS Plugin">https://raw.githubusercontent.com/mokoconsulting-tech/MokoJoomTOS/main/update.xml</server>
|
||||
<server type="extension" name="MokoJoomTOS Plugin">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/raw/branch/main/updates.xml</server>
|
||||
</updateservers>
|
||||
</extension>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>plg_system_mokojoomtos</name>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-01-01</creationDate>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>03.08.04</version>
|
||||
<description>PLG_SYSTEM_MOKOJOOMTOS_XML_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\System\MokoJoomTOS</namespace>
|
||||
|
||||
<scriptfile>script.php</scriptfile>
|
||||
|
||||
<files>
|
||||
<filename plugin="mokojoomtos">mokojoomtos.php</filename>
|
||||
<folder>src</folder>
|
||||
<folder>language</folder>
|
||||
<folder>administrator</folder>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_system_mokojoomtos.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_system_mokojoomtos.sys.ini</language>
|
||||
<language tag="en-US">language/en-US/plg_system_mokojoomtos.ini</language>
|
||||
<language tag="en-US">language/en-US/plg_system_mokojoomtos.sys.ini</language>
|
||||
<language tag="en-GB" folder="administrator">administrator/language/en-GB/plg_system_mokojoomtos.ini</language>
|
||||
<language tag="en-GB" folder="administrator">administrator/language/en-GB/plg_system_mokojoomtos.sys.ini</language>
|
||||
<language tag="en-US" folder="administrator">administrator/language/en-US/plg_system_mokojoomtos.ini</language>
|
||||
<language tag="en-US" folder="administrator">administrator/language/en-US/plg_system_mokojoomtos.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params" addfieldprefix="Joomla\Plugin\System\MokoJoomTOS\Field">
|
||||
<fieldset name="basic" label="PLG_SYSTEM_MOKOJOOMTOS_FIELDSET_BASIC">
|
||||
<field
|
||||
name="tos_slug"
|
||||
type="menuslug"
|
||||
label="PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_LABEL"
|
||||
description="PLG_SYSTEM_MOKOJOOMTOS_FIELD_TOS_SLUG_DESC"
|
||||
default="terms-of-service"
|
||||
required="true"
|
||||
/>
|
||||
|
||||
<field
|
||||
name="help_spacer"
|
||||
type="spacer"
|
||||
label="PLG_SYSTEM_MOKOJOOMTOS_HELP_LABEL"
|
||||
description="PLG_SYSTEM_MOKOJOOMTOS_HELP_DESC"
|
||||
class="alert alert-info"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
|
||||
<updateservers>
|
||||
<server type="extension" name="MokoJoomTOS Plugin">https://raw.githubusercontent.com/mokoconsulting-tech/MokoJoomTOS/main/update.xml</server>
|
||||
</updateservers>
|
||||
</extension>
|
||||
+447
-346
@@ -13,369 +13,470 @@ use Joomla\CMS\Installer\InstallerAdapter;
|
||||
use Joomla\CMS\Installer\InstallerScript;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Table\Table;
|
||||
|
||||
/**
|
||||
* Installation script for MokoJoomTOS Offline Plugin
|
||||
* Installation script for MokoJoomTOS Plugin
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class PlgSystemMokojoomtosOfflineInstallerScript extends InstallerScript
|
||||
class PlgSystemMokojoomtosInstallerScript extends InstallerScript
|
||||
{
|
||||
/**
|
||||
* Minimum Joomla version required to install the plugin
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $minimumJoomla = '4.0.0';
|
||||
/**
|
||||
* Minimum Joomla version required to install the plugin
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $minimumJoomla = '4.0.0';
|
||||
|
||||
/**
|
||||
* Minimum PHP version required to install the plugin
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $minimumPhp = '7.4.0';
|
||||
/**
|
||||
* Minimum PHP version required to install the plugin
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $minimumPhp = '7.4.0';
|
||||
|
||||
/**
|
||||
* Extension type (used by parent class)
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $extension = 'plg_system_mokojoomtos';
|
||||
/**
|
||||
* Extension type (used by parent class)
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $extension = 'plg_system_mokojoomtos';
|
||||
|
||||
/**
|
||||
* Function called before plugin installation/update/uninstall
|
||||
*
|
||||
* @param string $type Installation type (install, update, discover_install)
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return boolean True on success
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function preflight($type, $parent)
|
||||
{
|
||||
// Check minimum requirements
|
||||
if (!parent::preflight($type, $parent)) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Function called before plugin installation/update/uninstall
|
||||
*
|
||||
* @param string $type Installation type (install, update, discover_install)
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return boolean True on success
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function preflight($type, $parent)
|
||||
{
|
||||
if (!parent::preflight($type, $parent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function called after plugin installation
|
||||
*
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function install($parent)
|
||||
{
|
||||
echo '<p>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_INSTALL_SUCCESS') . '</p>';
|
||||
}
|
||||
/**
|
||||
* Function called after plugin installation
|
||||
*
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function install($parent)
|
||||
{
|
||||
echo '<p>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_INSTALL_SUCCESS') . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Function called after plugin update
|
||||
*
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function update($parent)
|
||||
{
|
||||
echo '<p>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_UPDATE_SUCCESS') . '</p>';
|
||||
}
|
||||
/**
|
||||
* Function called after plugin update
|
||||
*
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function update($parent)
|
||||
{
|
||||
echo '<p>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_UPDATE_SUCCESS') . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Function called after plugin uninstallation
|
||||
*
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function uninstall($parent)
|
||||
{
|
||||
echo '<p>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_UNINSTALL_SUCCESS') . '</p>';
|
||||
}
|
||||
/**
|
||||
* Function called after plugin uninstallation
|
||||
*
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function uninstall($parent)
|
||||
{
|
||||
echo '<p>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_UNINSTALL_SUCCESS') . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Function called after extension installation/update/discover_install
|
||||
*
|
||||
* @param string $type Installation type (install, update, discover_install)
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function postflight($type, $parent)
|
||||
{
|
||||
if ($type === 'install' || $type === 'discover_install') {
|
||||
// Create Terms of Service article and menu item
|
||||
$this->createTermsOfServiceSetup();
|
||||
|
||||
echo '<div class="alert alert-success">';
|
||||
echo '<h4>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_POSTINSTALL_TITLE') . '</h4>';
|
||||
echo '<p>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_POSTINSTALL_DESC') . '</p>';
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Function called after extension installation/update/discover_install
|
||||
*
|
||||
* Fixes #89: enablePlugin() is now called unconditionally for both
|
||||
* install and upgrade paths.
|
||||
* Fixes #92: enablePlugin() is called on upgrade to re-enable if disabled.
|
||||
*
|
||||
* @param string $type Installation type (install, update, discover_install)
|
||||
* @param InstallerAdapter $parent Parent installer adapter
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function postflight($type, $parent)
|
||||
{
|
||||
// Always enable the plugin on install or upgrade
|
||||
$this->enablePlugin();
|
||||
|
||||
/**
|
||||
* Create Terms of Service article and menu item
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createTermsOfServiceSetup()
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Check if Terms of Service article already exists
|
||||
$query = $db->getQuery(true)
|
||||
->select('id')
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote('terms-of-service'));
|
||||
$db->setQuery($query);
|
||||
$articleId = $db->loadResult();
|
||||
|
||||
// Create article if it doesn't exist
|
||||
if (!$articleId) {
|
||||
$articleId = $this->createTermsArticle();
|
||||
}
|
||||
|
||||
if ($articleId) {
|
||||
// Check if menu item already exists
|
||||
$query = $db->getQuery(true)
|
||||
->select('id')
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote('terms-of-service'))
|
||||
->where($db->quoteName('published') . ' >= 0');
|
||||
$db->setQuery($query);
|
||||
$menuId = $db->loadResult();
|
||||
|
||||
// Create menu item if it doesn't exist
|
||||
if (!$menuId) {
|
||||
$this->createTermsMenuItem($articleId);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::add('Error creating Terms of Service setup: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
}
|
||||
if ($type === 'install' || $type === 'discover_install') {
|
||||
$this->createTermsOfServiceSetup();
|
||||
$this->setDefaultSlugs();
|
||||
|
||||
/**
|
||||
* Create Terms of Service article
|
||||
*
|
||||
* @return int|null Article ID or null on failure
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createTermsArticle()
|
||||
{
|
||||
try {
|
||||
Table::addIncludePath(JPATH_ADMINISTRATOR . '/components/com_content/tables');
|
||||
$table = Table::getInstance('Content', 'Joomla\\Component\\Content\\Administrator\\Table\\');
|
||||
|
||||
if (!$table) {
|
||||
Log::add('Failed to get Content table instance', Log::WARNING, 'jerror');
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'title' => 'Terms of Service',
|
||||
'alias' => 'terms-of-service',
|
||||
'introtext' => '<h2>Terms of Service</h2><p>Welcome to our Terms of Service page.</p><p>This page will remain accessible even when the site is in offline/maintenance mode.</p>',
|
||||
'fulltext' => '',
|
||||
'state' => 1,
|
||||
'catid' => 2, // Uncategorised
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
'created_by' => 0, // System-created content
|
||||
'language' => '*',
|
||||
'access' => 1, // Public
|
||||
];
|
||||
|
||||
// Bind data to table object first
|
||||
if (!$table->bind($data)) {
|
||||
Log::add('Failed to bind data to Content table: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check data validity
|
||||
if (!$table->check()) {
|
||||
Log::add('Content table check failed: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Save the table
|
||||
if (!$table->store()) {
|
||||
Log::add('Failed to store Content table: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return null;
|
||||
}
|
||||
|
||||
echo '<p class="alert alert-info">✓ Created Terms of Service article</p>';
|
||||
return $table->id;
|
||||
} catch (Exception $e) {
|
||||
Log::add('Error creating Terms of Service article: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
echo '<div class="alert alert-success">';
|
||||
echo '<h4>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_POSTINSTALL_TITLE') . '</h4>';
|
||||
echo '<p>' . Text::_('PLG_SYSTEM_MOKOJOOMTOS_POSTINSTALL_DESC') . '</p>';
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Terms of Service menu item
|
||||
*
|
||||
* @param int $articleId The article ID to link to
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createTermsMenuItem($articleId)
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Check if "Legal" menu type exists
|
||||
$query = $db->getQuery(true)
|
||||
->select('id')
|
||||
->from($db->quoteName('#__menu_types'))
|
||||
->where($db->quoteName('menutype') . ' = ' . $db->quote('legal'));
|
||||
$db->setQuery($query);
|
||||
$legalMenuExists = $db->loadResult();
|
||||
|
||||
// Create "Legal" menu type if it doesn't exist
|
||||
if (!$legalMenuExists) {
|
||||
$this->createLegalMenuType();
|
||||
}
|
||||
|
||||
// Get com_content component ID dynamically
|
||||
$query = $db->getQuery(true)
|
||||
->select('extension_id')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_content'));
|
||||
$db->setQuery($query);
|
||||
$componentId = (int) $db->loadResult() ?: 22; // Fallback to 22 if query fails
|
||||
|
||||
Table::addIncludePath(JPATH_ADMINISTRATOR . '/components/com_menus/tables');
|
||||
$table = Table::getInstance('Menu', 'Joomla\\Component\\Menus\\Administrator\\Table\\');
|
||||
|
||||
if (!$table) {
|
||||
Log::add('Failed to get Menu table instance', Log::WARNING, 'jerror');
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'menutype' => 'legal',
|
||||
'title' => 'Terms of Service',
|
||||
'alias' => 'terms-of-service',
|
||||
'link' => 'index.php?option=com_content&view=article&id=' . $articleId,
|
||||
'type' => 'component',
|
||||
'published' => 1,
|
||||
'parent_id' => 1,
|
||||
'component_id' => $componentId,
|
||||
'level' => 1,
|
||||
'language' => '*',
|
||||
'access' => 1, // Public
|
||||
'params' => '{"show_title":"1","link_titles":"0","show_intro":"","info_block_position":"","show_category":"0","link_category":"0","show_parent_category":"0","link_parent_category":"0","show_author":"0","link_author":"0","show_create_date":"0","show_modify_date":"0","show_publish_date":"0","show_item_navigation":"0","show_icons":"0","show_print_icon":"0","show_email_icon":"0","show_hits":"0","show_noauth":"0","urls_position":"","menu-anchor_title":"","menu-anchor_css":"","menu_image":"","menu_text":1,"page_title":"","show_page_heading":0,"page_heading":"","pageclass_sfx":"","menu-meta_description":"","menu-meta_keywords":"","robots":"","secure":0}',
|
||||
];
|
||||
|
||||
// Set the location in the menu tree
|
||||
$table->setLocation($data['parent_id'], 'last-child');
|
||||
|
||||
// Bind data to table object
|
||||
if (!$table->bind($data)) {
|
||||
Log::add('Failed to bind data to Menu table: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check data validity
|
||||
if (!$table->check()) {
|
||||
Log::add('Menu table check failed: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the menu item
|
||||
if (!$table->store()) {
|
||||
Log::add('Failed to store Menu table: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<p class="alert alert-info">✓ Created Terms of Service menu item in Legal menu with slug: terms-of-service</p>';
|
||||
|
||||
// Enable the plugin
|
||||
$this->enablePlugin();
|
||||
} catch (Exception $e) {
|
||||
Log::add('Error creating Terms of Service menu item: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create Terms of Service article and menu item
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createTermsOfServiceSetup()
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
/**
|
||||
* Create Legal menu type
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createLegalMenuType()
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Insert the Legal menu type
|
||||
$query = $db->getQuery(true)
|
||||
->insert($db->quoteName('#__menu_types'))
|
||||
->columns($db->quoteName(['menutype', 'title', 'description']))
|
||||
->values(
|
||||
$db->quote('legal') . ', ' .
|
||||
$db->quote('Legal') . ', ' .
|
||||
$db->quote('Legal documents and policies menu')
|
||||
);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
echo '<p class="alert alert-info">✓ Created Legal menu type</p>';
|
||||
} catch (Exception $e) {
|
||||
Log::add('Error creating Legal menu type: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
}
|
||||
// Check if Terms of Service article already exists (by alias, any category)
|
||||
$query = $db->getQuery(true)
|
||||
->select('id')
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote('terms-of-service'))
|
||||
->where($db->quoteName('state') . ' >= 0');
|
||||
$db->setQuery($query);
|
||||
$articleId = $db->loadResult();
|
||||
|
||||
/**
|
||||
* Enable the plugin after installation
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function enablePlugin()
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 1')
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokojoomtos'));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
echo '<p class="alert alert-success">✓ Plugin enabled automatically</p>';
|
||||
} catch (Exception $e) {
|
||||
Log::add('Error enabling plugin: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
}
|
||||
if (!$articleId) {
|
||||
$articleId = $this->createTermsArticle();
|
||||
}
|
||||
|
||||
if ($articleId) {
|
||||
$query = $db->getQuery(true)
|
||||
->select('id')
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote('terms-of-service'))
|
||||
->where($db->quoteName('published') . ' >= 0');
|
||||
$db->setQuery($query);
|
||||
$menuId = $db->loadResult();
|
||||
|
||||
if (!$menuId) {
|
||||
$this->createTermsMenuItem($articleId);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::add('Error creating Terms of Service setup: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Terms of Service article using Joomla 5 MVCFactory
|
||||
*
|
||||
* Fixes #90: Uses bootComponent()->getMVCFactory() instead of
|
||||
* the removed Table::addIncludePath() / Table::getInstance().
|
||||
* Fixes #94: Includes params, metadata, and attribs defaults.
|
||||
*
|
||||
* @return int|null Article ID or null on failure
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createTermsArticle()
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$app = Factory::getApplication();
|
||||
|
||||
// Get content table via MVCFactory (Joomla 4/5 compatible)
|
||||
$table = $app->bootComponent('com_content')
|
||||
->getMVCFactory()
|
||||
->createTable('Article', 'Administrator');
|
||||
|
||||
if (!$table) {
|
||||
Log::add('Failed to get Content table instance', Log::WARNING, 'jerror');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get Uncategorised category ID dynamically
|
||||
$query = $db->getQuery(true)
|
||||
->select('id')
|
||||
->from($db->quoteName('#__categories'))
|
||||
->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'))
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote('uncategorised'))
|
||||
->where($db->quoteName('published') . ' = 1');
|
||||
$db->setQuery($query);
|
||||
$catId = (int) $db->loadResult();
|
||||
|
||||
if (!$catId) {
|
||||
Log::add('Could not find Uncategorised category for com_content', Log::WARNING, 'jerror');
|
||||
return null;
|
||||
}
|
||||
|
||||
$createdBy = $app->getIdentity()->id ?: 0;
|
||||
|
||||
$data = [
|
||||
'title' => 'Terms of Service',
|
||||
'alias' => 'terms-of-service',
|
||||
'introtext' => '<h2>Terms of Service</h2><p>Welcome to our Terms of Service page.</p><p>This page will remain accessible even when the site is in offline/maintenance mode.</p>',
|
||||
'fulltext' => '',
|
||||
'state' => 1,
|
||||
'catid' => $catId,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
'created_by' => $createdBy,
|
||||
'language' => '*',
|
||||
'access' => 1,
|
||||
'params' => '{}',
|
||||
'metadata' => '{"robots":"","author":"","rights":""}',
|
||||
'attribs' => '{}',
|
||||
];
|
||||
|
||||
if (!$table->bind($data)) {
|
||||
Log::add('Failed to bind data to Content table: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$table->check()) {
|
||||
Log::add('Content table check failed: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$table->store()) {
|
||||
Log::add('Failed to store Content table: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return null;
|
||||
}
|
||||
|
||||
echo '<p class="alert alert-info">Created Terms of Service article</p>';
|
||||
return $table->id;
|
||||
} catch (\Throwable $e) {
|
||||
Log::add('Error creating Terms of Service article: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Terms of Service menu item using Joomla 5 MVCFactory
|
||||
*
|
||||
* Fixes #90: Uses bootComponent()->getMVCFactory() instead of
|
||||
* the removed Table::addIncludePath() / Table::getInstance().
|
||||
*
|
||||
* @param int $articleId The article ID to link to
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createTermsMenuItem($articleId)
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$app = Factory::getApplication();
|
||||
|
||||
// Check if "Legal" menu type exists
|
||||
$query = $db->getQuery(true)
|
||||
->select('id')
|
||||
->from($db->quoteName('#__menu_types'))
|
||||
->where($db->quoteName('menutype') . ' = ' . $db->quote('legal'));
|
||||
$db->setQuery($query);
|
||||
$legalMenuExists = $db->loadResult();
|
||||
|
||||
if (!$legalMenuExists) {
|
||||
$this->createLegalMenuType();
|
||||
}
|
||||
|
||||
// Get com_content component ID dynamically
|
||||
$query = $db->getQuery(true)
|
||||
->select('extension_id')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_content'));
|
||||
$db->setQuery($query);
|
||||
$componentId = (int) $db->loadResult();
|
||||
|
||||
if (!$componentId) {
|
||||
Log::add('Could not determine com_content component ID', Log::WARNING, 'jerror');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get menu table via MVCFactory (Joomla 4/5 compatible)
|
||||
$table = $app->bootComponent('com_menus')
|
||||
->getMVCFactory()
|
||||
->createTable('Menu', 'Administrator');
|
||||
|
||||
if (!$table) {
|
||||
Log::add('Failed to get Menu table instance', Log::WARNING, 'jerror');
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'menutype' => 'legal',
|
||||
'title' => 'Terms of Service',
|
||||
'alias' => 'terms-of-service',
|
||||
'link' => 'index.php?option=com_content&view=article&id=' . $articleId,
|
||||
'type' => 'component',
|
||||
'published' => 1,
|
||||
'parent_id' => 1,
|
||||
'component_id' => $componentId,
|
||||
'level' => 1,
|
||||
'language' => '*',
|
||||
'access' => 1,
|
||||
'params' => '{"show_title":"1","link_titles":"0","show_intro":"","info_block_position":"","show_category":"0","link_category":"0","show_parent_category":"0","link_parent_category":"0","show_author":"0","link_author":"0","show_create_date":"0","show_modify_date":"0","show_publish_date":"0","show_item_navigation":"0","show_icons":"0","show_print_icon":"0","show_email_icon":"0","show_hits":"0","show_noauth":"0","urls_position":"","menu-anchor_title":"","menu-anchor_css":"","menu_image":"","menu_text":1,"page_title":"","show_page_heading":0,"page_heading":"","pageclass_sfx":"","menu-meta_description":"","menu-meta_keywords":"","robots":"","secure":0}',
|
||||
];
|
||||
|
||||
$table->setLocation($data['parent_id'], 'last-child');
|
||||
|
||||
if (!$table->bind($data)) {
|
||||
Log::add('Failed to bind data to Menu table: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$table->check()) {
|
||||
Log::add('Menu table check failed: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$table->store()) {
|
||||
Log::add('Failed to store Menu table: ' . $table->getError(), Log::WARNING, 'jerror');
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<p class="alert alert-info">Created Terms of Service menu item in Legal menu</p>';
|
||||
} catch (\Throwable $e) {
|
||||
Log::add('Error creating Terms of Service menu item: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Legal menu type
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createLegalMenuType()
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->insert($db->quoteName('#__menu_types'))
|
||||
->columns($db->quoteName(['menutype', 'title', 'description']))
|
||||
->values(
|
||||
$db->quote('legal') . ', ' .
|
||||
$db->quote('Legal') . ', ' .
|
||||
$db->quote('Legal documents and policies menu')
|
||||
);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
echo '<p class="alert alert-info">Created Legal menu type</p>';
|
||||
} catch (\Throwable $e) {
|
||||
// Duplicate key is expected if race condition — safe to ignore
|
||||
Log::add('Error creating Legal menu type: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-select default menu slugs (terms-of-service, privacy-policy)
|
||||
*
|
||||
* Looks up menu items matching common legal page aliases and sets
|
||||
* them as the default tos_slug parameter so the plugin works
|
||||
* immediately after install with zero configuration.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.2.1
|
||||
*/
|
||||
private function setDefaultSlugs()
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$defaultAliases = ['terms-of-service', 'privacy-policy'];
|
||||
$slugs = [];
|
||||
|
||||
foreach ($defaultAliases as $alias) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('path'))
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($alias))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('client_id') . ' = 0');
|
||||
$db->setQuery($query);
|
||||
$path = $db->loadResult();
|
||||
|
||||
if ($path) {
|
||||
$slugs[] = trim($path, '/');
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($slugs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load current plugin params
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokojoomtos'));
|
||||
$db->setQuery($query);
|
||||
$paramsJson = $db->loadResult();
|
||||
|
||||
$params = json_decode($paramsJson ?: '{}', true) ?: [];
|
||||
|
||||
// Only set defaults if no slugs are already configured
|
||||
$existing = $params['tos_slug'] ?? [];
|
||||
|
||||
if (!empty($existing)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$params['tos_slug'] = $slugs;
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokojoomtos'));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
} catch (\Throwable $e) {
|
||||
Log::add('Error setting default slugs: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the plugin after installation
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function enablePlugin()
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 1')
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokojoomtos'));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
} catch (\Throwable $e) {
|
||||
Log::add('Error enabling plugin: ' . $e->getMessage(), Log::WARNING, 'jerror');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoJoomTOS
|
||||
* @subpackage plg_system_mokojoomtos
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Joomla\Plugin\System\MokoJoomTOS\Extension\MokoJoomTOS;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
/**
|
||||
* Registers the service provider with a DI container.
|
||||
*
|
||||
* @param Container $container The DI container.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 04.00.00
|
||||
*/
|
||||
public function register(Container $container)
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$plugin = new MokoJoomTOS(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('system', 'mokojoomtos')
|
||||
);
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -18,106 +18,227 @@ use Joomla\Event\SubscriberInterface;
|
||||
/**
|
||||
* MokoJoomTOS Offline Mode Bypass Plugin
|
||||
*
|
||||
* Allows Terms of Service menu to be accessible via slug when the site
|
||||
* Allows configured menu items to remain accessible when the site
|
||||
* is in offline mode.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
final class MokoJoomTOS extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
/**
|
||||
* Load the language file on instantiation.
|
||||
*
|
||||
* @var boolean
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $autoloadLanguage = true;
|
||||
/**
|
||||
* Load the language file on instantiation.
|
||||
*
|
||||
* @var boolean
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
/**
|
||||
* Application object
|
||||
*
|
||||
* @var \Joomla\CMS\Application\CMSApplication
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $app;
|
||||
/**
|
||||
* Returns an array of events this subscriber will listen to.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onAfterRoute' => 'onAfterRoute',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of events this subscriber will listen to.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onAfterRoute' => 'onAfterRoute',
|
||||
];
|
||||
}
|
||||
/**
|
||||
* After route event handler
|
||||
*
|
||||
* Checks if the current request matches any configured menu slug and if
|
||||
* the site is in offline mode. If both conditions are met, temporarily
|
||||
* disables offline mode and sets component-only view for this request.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function onAfterRoute()
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
/**
|
||||
* After route event handler
|
||||
*
|
||||
* Checks if the current request is for the Terms of Service slug and if
|
||||
* the site is in offline mode. If both conditions are met, temporarily
|
||||
* disables offline mode and sets component-only view for this request.
|
||||
*
|
||||
* This event fires after routing but before template selection, making it
|
||||
* the correct place to set tmpl=component to prevent template chrome loading.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function onAfterRoute()
|
||||
{
|
||||
// Only process for site application
|
||||
if (!$this->app->isClient('site'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Only process for site application
|
||||
if (!$app->isClient('site'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the global configuration
|
||||
$config = $this->app->getConfig();
|
||||
|
||||
// Only proceed if site is offline
|
||||
if (!$config->get('offline'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
$config = $app->getConfig();
|
||||
|
||||
// Get the configured Terms of Service slug
|
||||
$tosSlug = trim($this->params->get('tos_slug', 'terms-of-service'));
|
||||
|
||||
if (empty($tosSlug))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Only proceed if site is offline
|
||||
if (!$config->get('offline'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current URI path
|
||||
$uri = Uri::getInstance();
|
||||
$path = trim($uri->getPath(), '/');
|
||||
|
||||
// Remove the base path if present
|
||||
$base = trim(Uri::base(true), '/');
|
||||
if (!empty($base) && strpos($path, $base) === 0)
|
||||
{
|
||||
$path = trim(substr($path, strlen($base)), '/');
|
||||
}
|
||||
// Get the configured slugs — cast to array to handle stdClass from Registry (#96)
|
||||
$slugs = $this->params->get('tos_slug', []);
|
||||
|
||||
// Check if the path matches the Terms of Service slug
|
||||
if ($path === $tosSlug || strpos($path, $tosSlug . '/') === 0)
|
||||
{
|
||||
// Temporarily disable offline mode for this request
|
||||
$config->set('offline', 0);
|
||||
|
||||
// Set component-only view (no template chrome)
|
||||
// This ensures clean display without full site template
|
||||
$input = $this->app->input;
|
||||
$input->set('tmpl', 'component');
|
||||
|
||||
// Also set it in the GET superglobal to ensure it's recognized
|
||||
$_GET['tmpl'] = 'component';
|
||||
}
|
||||
}
|
||||
// Handle legacy single-value string format
|
||||
if (is_string($slugs))
|
||||
{
|
||||
$slugs = array_filter([trim($slugs)]);
|
||||
}
|
||||
else
|
||||
{
|
||||
$slugs = (array) $slugs;
|
||||
}
|
||||
|
||||
if (empty($slugs))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$includeChildren = (int) $this->params->get('include_children', 1);
|
||||
|
||||
// Try SEF path matching first, then fall back to Itemid matching (#91)
|
||||
if ($this->matchByPath($slugs, $config, $app, $includeChildren))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$this->matchByItemId($slugs, $config, $app, $includeChildren);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the current request path against configured slugs (SEF mode)
|
||||
*
|
||||
* @param array $slugs Configured slug values
|
||||
* @param object $config Joomla configuration object
|
||||
* @param object $app Application instance
|
||||
* @param integer $includeChildren Whether to include child menu items
|
||||
*
|
||||
* @return boolean True if a match was found and offline mode was bypassed
|
||||
*
|
||||
* @since 4.1.0
|
||||
*/
|
||||
private function matchByPath(array $slugs, $config, $app, int $includeChildren = 1): bool
|
||||
{
|
||||
$uri = Uri::getInstance();
|
||||
$path = urldecode(trim($uri->getPath(), '/'));
|
||||
|
||||
// Remove the base path if present (subdirectory installs)
|
||||
$base = trim(Uri::base(true), '/');
|
||||
if (!empty($base) && strpos($path, $base) === 0)
|
||||
{
|
||||
$path = trim(substr($path, strlen($base)), '/');
|
||||
}
|
||||
|
||||
// Skip if path is empty or just index.php (non-SEF)
|
||||
if (empty($path) || $path === 'index.php')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($slugs as $slug)
|
||||
{
|
||||
$slug = trim((string) $slug);
|
||||
if (empty($slug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$isMatch = ($path === $slug)
|
||||
|| ($includeChildren && strpos($path, $slug . '/') === 0);
|
||||
|
||||
if ($isMatch)
|
||||
{
|
||||
$this->bypassOffline($config, $app);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the current request Itemid against menu items for configured slugs (non-SEF fallback)
|
||||
*
|
||||
* When SEF URLs are disabled, the path is just index.php so we match by
|
||||
* checking if the requested Itemid belongs to a menu item whose path
|
||||
* matches a configured slug.
|
||||
*
|
||||
* @param array $slugs Configured slug values
|
||||
* @param object $config Joomla configuration object
|
||||
* @param object $app Application instance
|
||||
* @param integer $includeChildren Whether to include child menu items
|
||||
*
|
||||
* @return boolean True if a match was found and offline mode was bypassed
|
||||
*
|
||||
* @since 4.1.0
|
||||
*/
|
||||
private function matchByItemId(array $slugs, $config, $app, int $includeChildren = 1): bool
|
||||
{
|
||||
$itemId = (int) $app->input->getInt('Itemid', 0);
|
||||
|
||||
if (!$itemId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('path'))
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('id') . ' = ' . $itemId)
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('client_id') . ' = 0');
|
||||
$db->setQuery($query);
|
||||
$menuPath = $db->loadResult();
|
||||
|
||||
if (!$menuPath)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$menuPath = trim($menuPath, '/');
|
||||
|
||||
foreach ($slugs as $slug)
|
||||
{
|
||||
$slug = trim((string) $slug);
|
||||
if (empty($slug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$isMatch = ($menuPath === $slug)
|
||||
|| ($includeChildren && strpos($menuPath, $slug . '/') === 0);
|
||||
|
||||
if ($isMatch)
|
||||
{
|
||||
$this->bypassOffline($config, $app);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silently fail — do not bypass offline mode on error
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass offline mode and set component-only view for this request
|
||||
*
|
||||
* @param object $config Joomla configuration object
|
||||
* @param object $app Application instance
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.1.0
|
||||
*/
|
||||
private function bypassOffline($config, $app): void
|
||||
{
|
||||
$config->set('offline', 0);
|
||||
$app->input->set('tmpl', 'component');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,83 +17,99 @@ use Joomla\CMS\Language\Text;
|
||||
/**
|
||||
* Menu Slug Field
|
||||
*
|
||||
* Provides a dropdown list of menu items with their aliases (slugs)
|
||||
* Provides a multi-select dropdown of menu items with their full route paths
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class MenuslugField extends ListField
|
||||
{
|
||||
/**
|
||||
* The form field type.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $type = 'Menuslug';
|
||||
/**
|
||||
* The form field type.
|
||||
*
|
||||
* @var string
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $type = 'Menuslug';
|
||||
|
||||
/**
|
||||
* Method to get the field options.
|
||||
*
|
||||
* @return array The field option objects.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function getOptions()
|
||||
{
|
||||
$options = parent::getOptions();
|
||||
/**
|
||||
* Method to get the field options.
|
||||
*
|
||||
* @return array The field option objects.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected function getOptions()
|
||||
{
|
||||
$options = parent::getOptions();
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['alias', 'title', 'menutype']))
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('client_id') . ' = 0')
|
||||
->where($db->quoteName('alias') . ' != ' . $db->quote(''))
|
||||
->order($db->quoteName('menutype') . ', ' . $db->quoteName('title'));
|
||||
// Warn if SEF URLs are disabled (#97)
|
||||
try
|
||||
{
|
||||
$sef = Factory::getApplication()->get('sef', true);
|
||||
if (!$sef)
|
||||
{
|
||||
$options[] = (object) [
|
||||
'value' => '',
|
||||
'text' => Text::_('PLG_SYSTEM_MOKOJOOMTOS_FIELD_SEF_WARNING'),
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
// Ignore — field still works without the warning
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$menuItems = $db->loadObjectList();
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['path', 'alias', 'title', 'menutype']))
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('client_id') . ' = 0')
|
||||
->where($db->quoteName('alias') . ' != ' . $db->quote(''))
|
||||
->order($db->quoteName('menutype') . ', ' . $db->quoteName('title'));
|
||||
|
||||
if ($menuItems)
|
||||
{
|
||||
$lastMenuType = '';
|
||||
|
||||
foreach ($menuItems as $item)
|
||||
{
|
||||
// Add menu type separator for better organization
|
||||
if ($item->menutype !== $lastMenuType)
|
||||
{
|
||||
if ($lastMenuType !== '')
|
||||
{
|
||||
// Add a separator between menu types
|
||||
$options[] = (object) [
|
||||
'value' => '',
|
||||
'text' => '──────────────',
|
||||
'disable' => true
|
||||
];
|
||||
}
|
||||
$lastMenuType = $item->menutype;
|
||||
}
|
||||
$db->setQuery($query);
|
||||
$menuItems = $db->loadObjectList();
|
||||
|
||||
$displayText = $item->title !== '' ? $item->title : ucwords(str_replace(['-', '_'], ' ', $item->alias));
|
||||
$options[] = (object) [
|
||||
'value' => $item->alias,
|
||||
'text' => $displayText . ' (' . $item->alias . ')'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
// Log error but don't break the form
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
Text::sprintf('PLG_SYSTEM_MOKOJOOMTOS_ERROR_LOADING_MENU_ITEMS', $e->getMessage()),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
if ($menuItems)
|
||||
{
|
||||
$lastMenuType = '';
|
||||
|
||||
return $options;
|
||||
}
|
||||
foreach ($menuItems as $item)
|
||||
{
|
||||
// Add menu type separator for better organization
|
||||
if ($item->menutype !== $lastMenuType)
|
||||
{
|
||||
if ($lastMenuType !== '')
|
||||
{
|
||||
$options[] = (object) [
|
||||
'value' => '',
|
||||
'text' => '──────────────',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
$lastMenuType = $item->menutype;
|
||||
}
|
||||
|
||||
$displayText = $item->title !== '' ? $item->title : ucwords(str_replace(['-', '_'], ' ', $item->alias));
|
||||
$options[] = (object) [
|
||||
'value' => $item->path,
|
||||
'text' => $displayText . ' (/' . $item->path . ')'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
Text::sprintf('PLG_SYSTEM_MOKOJOOMTOS_ERROR_LOADING_MENU_ITEMS', $e->getMessage()),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
<!--
|
||||
Joomla Extension Update Server XML
|
||||
See: https://docs.joomla.org/Deploying_an_Update_Server
|
||||
|
||||
This file is the update server manifest for {{EXTENSION_NAME}}.
|
||||
The Joomla installer polls this URL to check for new versions.
|
||||
|
||||
The manifest.xml in this repository must reference this file:
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="{{EXTENSION_NAME}}">
|
||||
https://git.mokoconsulting.tech/mokoconsulting-tech/MokoJoomTOS/raw/branch/main/update.xml
|
||||
</server>
|
||||
<server type="extension" priority="2" name="{{EXTENSION_NAME}}">
|
||||
https://raw.githubusercontent.com/mokoconsulting-tech/MokoJoomTOS/main/update.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
|
||||
When a new release is made, run `make release` or the release workflow to
|
||||
prepend a new <update> entry to this file automatically.
|
||||
-->
|
||||
<updates>
|
||||
<update>
|
||||
<name>{{EXTENSION_NAME}}</name>
|
||||
<description>MokoJoomTOS — Moko Consulting Joomla extension</description>
|
||||
<element>{{EXTENSION_ELEMENT}}</element>
|
||||
<type>{{EXTENSION_TYPE}}</type>
|
||||
<version>{{VERSION}}</version>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">
|
||||
https://git.mokoconsulting.tech/mokoconsulting-tech/MokoJoomTOS/releases/download/v{{VERSION}}/{{EXTENSION_ELEMENT}}.zip
|
||||
</downloadurl>
|
||||
<downloadurl type="full" format="zip">
|
||||
https://github.com/mokoconsulting-tech/MokoJoomTOS/releases/download/v{{VERSION}}/{{EXTENSION_ELEMENT}}.zip
|
||||
</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="[56].*"/>
|
||||
<php_minimum>8.1</php_minimum>
|
||||
</update>
|
||||
</updates>
|
||||
+90
-13
@@ -1,20 +1,97 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
VERSION: 04.03.00
|
||||
-->
|
||||
|
||||
<updates>
|
||||
<update>
|
||||
<name></name>
|
||||
<description> update</description>
|
||||
<element></element>
|
||||
<type>component</type>
|
||||
<version></version>
|
||||
<tags>
|
||||
<tag>stable</tag>
|
||||
</tags>
|
||||
<infourl title="">https://github.com/mokoconsulting-tech/MokoJoomTOS</infourl>
|
||||
<name>System - Moko Terms of Service</name>
|
||||
<description>System - Moko Terms of Service update</description>
|
||||
<element>mokojoomtos</element>
|
||||
<type>plugin</type>
|
||||
<version>04.03.00-dev</version>
|
||||
<client>site</client>
|
||||
<folder>system</folder>
|
||||
<tags><tag>development</tag></tags>
|
||||
<infourl title="System - Moko Terms of Service">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/tag/development</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://github.com/mokoconsulting-tech/MokoJoomTOS/releases/download/v/-.zip</downloadurl>
|
||||
<downloadurl type="full" format="tar.gz">https://github.com/mokoconsulting-tech/MokoJoomTOS/releases/download/v/-.tar.gz</downloadurl>
|
||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/download/v04/plg_system_mokojoomtos-04.03.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="5.*" />
|
||||
<sha256>7668df3f07eef38c8172dcde129a29078736f0a511b51446ec1cc51bc2bd2e4c</sha256>
|
||||
<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
</update>
|
||||
<update>
|
||||
<name>System - Moko Terms of Service</name>
|
||||
<description>System - Moko Terms of Service update</description>
|
||||
<element>mokojoomtos</element>
|
||||
<type>plugin</type>
|
||||
<version>04.03.00-alpha</version>
|
||||
<client>site</client>
|
||||
<folder>system</folder>
|
||||
<tags><tag>alpha</tag></tags>
|
||||
<infourl title="System - Moko Terms of Service">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/tag/alpha</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/download/v04/plg_system_mokojoomtos-04.03.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>7668df3f07eef38c8172dcde129a29078736f0a511b51446ec1cc51bc2bd2e4c</sha256>
|
||||
<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
</update>
|
||||
<update>
|
||||
<name>System - Moko Terms of Service</name>
|
||||
<description>System - Moko Terms of Service update</description>
|
||||
<element>mokojoomtos</element>
|
||||
<type>plugin</type>
|
||||
<version>04.03.00-beta</version>
|
||||
<client>site</client>
|
||||
<folder>system</folder>
|
||||
<tags><tag>beta</tag></tags>
|
||||
<infourl title="System - Moko Terms of Service">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/tag/beta</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/download/v04/plg_system_mokojoomtos-04.03.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>7668df3f07eef38c8172dcde129a29078736f0a511b51446ec1cc51bc2bd2e4c</sha256>
|
||||
<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
</update>
|
||||
<update>
|
||||
<name>System - Moko Terms of Service</name>
|
||||
<description>System - Moko Terms of Service update</description>
|
||||
<element>mokojoomtos</element>
|
||||
<type>plugin</type>
|
||||
<version>04.03.00-rc</version>
|
||||
<client>site</client>
|
||||
<folder>system</folder>
|
||||
<tags><tag>rc</tag></tags>
|
||||
<infourl title="System - Moko Terms of Service">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/tag/rc</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/download/v04/plg_system_mokojoomtos-04.03.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>7668df3f07eef38c8172dcde129a29078736f0a511b51446ec1cc51bc2bd2e4c</sha256>
|
||||
<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
</update>
|
||||
<update>
|
||||
<name>System - Moko Terms of Service</name>
|
||||
<description>System - Moko Terms of Service update</description>
|
||||
<element>mokojoomtos</element>
|
||||
<type>plugin</type>
|
||||
<version>04.03.00</version>
|
||||
<client>site</client>
|
||||
<folder>system</folder>
|
||||
<tags><tag>stable</tag></tags>
|
||||
<infourl title="System - Moko Terms of Service">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/tag/stable</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/releases/download/v04/plg_system_mokojoomtos-04.03.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>7668df3f07eef38c8172dcde129a29078736f0a511b51446ec1cc51bc2bd2e4c</sha256>
|
||||
<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
</update>
|
||||
|
||||
Reference in New Issue
Block a user