Compare commits
293 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 560c7458c6 | |||
| e39b617464 | |||
| dac22fdcc4 | |||
| 68eab6fdb2 | |||
| b033cfe4e2 | |||
| e86bb5906b | |||
| b310ddfab2 | |||
| fa12fa5937 | |||
| b52867614c | |||
| b140bc9000 | |||
| 1a16f9ef8e | |||
| 7cdf8b4693 | |||
| d4b24fb57e | |||
| 6169716154 | |||
| 5904bea91d | |||
| 6ef4331f4c | |||
| 1533efc2e0 | |||
| 86aa6f22b7 | |||
| f1aa3867d8 | |||
| 549a3b5599 | |||
| 19e177f1a4 | |||
| 66b78fd712 | |||
| e66e003748 | |||
| dfd0fef3b8 | |||
| 26328d530e | |||
| 77da0c5517 | |||
| 6a928f856f | |||
| fa75b7d9c4 | |||
| 46e30c950b | |||
| 77148d2401 | |||
| 38af92b876 | |||
| 28d44d6884 | |||
| 3d2d91ace5 | |||
| 0cc69b7d77 | |||
| 1375c5820e | |||
| 0e6137b064 | |||
| e105474c68 | |||
| 7fd716f3a4 | |||
| ca06c86328 | |||
| 4492d1cbf8 | |||
| 7a7041c7f3 | |||
| f484675300 | |||
| 8793e6b3f4 | |||
| 0afc8b135a | |||
| 433ecfea71 | |||
| d4de07ffd0 | |||
| cf372c7fc7 | |||
| 1721e0b17d | |||
| 444959617b | |||
| 5db4c7902c | |||
| 8e34b1359a | |||
| ed57371d80 | |||
| 2131e7b975 | |||
| 04940f502a | |||
| 806539f684 | |||
| 3ea0d001e1 | |||
| ec1003db23 | |||
| 8f30eab945 | |||
| 8801e2761a | |||
| b26864509b | |||
| 227779d7b9 | |||
| 8e0afc0b14 | |||
| 0fbb3ccc46 | |||
| d6debe63f1 | |||
| c95b284791 | |||
| 1ea8b55711 | |||
| e226cc9a92 | |||
| 2d7fd7583b | |||
| 10e84d75c7 | |||
| b92eb553fc | |||
| 7df858a263 | |||
| a3e7644bdf | |||
| 6782ccd26d | |||
| 9f850042fa | |||
| 3932a33122 | |||
| 89eb668b13 | |||
| 4808aaa37d | |||
| fe7d2d16c7 | |||
| d627f5c82f | |||
| dc81e3de33 | |||
| 16622fc27c | |||
| 8684444478 | |||
| 703290fea8 | |||
| 1c6c305fb2 | |||
| 8e24fa353b | |||
| c9bff7c8a3 | |||
| e9d0e3a123 | |||
| 660c01441a | |||
| 3ba82cd272 | |||
| f1a5737739 | |||
| 7e9e0ec842 | |||
| 48facf09d7 | |||
| 3f4b8a0a3d | |||
| 2bf63f70ab | |||
| 64ac7796b7 | |||
| 3612ef2504 | |||
| 57f68c5402 | |||
| 93bf02d1d5 | |||
| d4d3ddd25c | |||
| f002f30580 | |||
| 0dd7c2120e | |||
| a67cd6da76 | |||
| 1593f28f19 | |||
| d55fb0d38f | |||
| a6e350d3b4 | |||
| 1933586768 | |||
| b108d083b2 | |||
| 426af7a879 | |||
| 493fcb6dd5 | |||
| 05b2a44209 | |||
| 3d315df905 | |||
| 252d75c44f | |||
| 8fe8469287 | |||
| 537f4539c8 | |||
| 036f8b9877 | |||
| b2d2ae9eff | |||
| ba22067c56 | |||
| 2c0cbc5a13 | |||
| 7de46486e6 | |||
| 6beea230a8 | |||
| 8b7b84dd41 | |||
| daa17a627e | |||
| cd76449f79 | |||
| cd0590cee4 | |||
| f960b37b33 | |||
| 05914c0c70 | |||
| 40de6532d6 | |||
| 6b752babd3 | |||
| 364bba0271 | |||
| 466eb7da3c | |||
| 7091e64e2c | |||
| 26e146bcf4 | |||
| 2bb8aaf8b4 | |||
| 9d0d772dd4 | |||
| 5d32a37258 | |||
| 922f74d37a | |||
| 6a38ca957a | |||
| 2dbb285fdf | |||
| 52d67f5fb1 | |||
| 42ca6325c7 | |||
| 87fc0930a0 | |||
| 23caf15df6 | |||
| d001ef7c35 | |||
| 2dcc6860c2 | |||
| 01055aa844 | |||
| 8c0fcfe81f | |||
| ba62e95e58 | |||
| 90e5f8c717 | |||
| e1747da3ff | |||
| ad09eae874 | |||
| 5cbd0b64d4 | |||
| 073e24bf4e | |||
| 5a6f315403 | |||
| 516e2a4a47 | |||
| fe92892310 | |||
| e1cf4cb385 | |||
| 381557e79b | |||
| 5da49ce416 | |||
| ee060243f5 | |||
| 9bd14d3547 | |||
| e1eb409943 | |||
| 5125fff078 | |||
| de9f7eeb58 | |||
| a5b14048f4 | |||
| b75e7ccf10 | |||
| d46f726a78 | |||
| 8fd232c959 | |||
| 0ff6798918 | |||
| 5c175ebc21 | |||
| fde6df7398 | |||
| ec8545c7d3 | |||
| 63e87a0c4d | |||
| 12143fc4b1 | |||
| abb9238ebe | |||
| 182e8caa90 | |||
| d162f2317c | |||
| 56d7d8784b | |||
| c02bb54759 | |||
| 4b682c5ebd | |||
| e0b4008ac7 | |||
| bd220a7d7c | |||
| 9d79830b02 | |||
| 8610aa5fcd | |||
| efd86ee77e | |||
| 821a3398a5 | |||
| 7e4b391004 | |||
| 354081d7a5 | |||
| 1ad79be9b2 | |||
| 9a88daec9d | |||
| 29917529ca | |||
| afb431a62c | |||
| 4fef3cd528 | |||
| 5d34cbced4 | |||
| 90bcabd2fa | |||
| a1d5953015 | |||
| f3a76c93be | |||
| 72639c1155 | |||
| 000d9a8fb6 | |||
| 8fb6a84e81 | |||
| 129a9d547b | |||
| 7da249ea73 | |||
| e1c826a46a | |||
| 9d628412e0 | |||
| 2c90881a5b | |||
| 516f7b4832 | |||
| d35660b4cf | |||
| f86d1bca41 | |||
| be05c56d29 | |||
| 943b5077b3 | |||
| 608ad43242 | |||
| 1954b36720 | |||
| f92b53d24e | |||
| fa732f44c8 | |||
| 6d6840c68b | |||
| 2b5b42567b | |||
| 485b0cf696 | |||
| 72e0f85e4b | |||
| a8805d16f1 | |||
| e4dad451b4 | |||
| 42d9de47d5 | |||
| 105d282077 | |||
| 9753088798 | |||
| 23de6dcb07 | |||
| b01107d6e6 | |||
| bbda9318d8 | |||
| fd7ccfb927 | |||
| 292265841e | |||
| 3563c62ec2 | |||
| 2fd22c6030 | |||
| 8054c6c80b | |||
| 7c90f05e9d | |||
| 648549ea66 | |||
| 52b2d89bc5 | |||
| 0b97a62c82 | |||
| fc148084a1 | |||
| 11ad5be25e | |||
| 0509980b50 | |||
| f7826ca60a | |||
| 400d3759e5 | |||
| 78fbe252cb | |||
| 3d855f829f | |||
| 6674f354c6 | |||
| 4a9433d2bb | |||
| 3d73ea2f9c | |||
| 9e953646d7 | |||
| a34091ffbe | |||
| e6ee93b79b | |||
| 4c9486173b | |||
| 9a6cd6fce8 | |||
| 3ec4b163bb | |||
| 15ec388b52 | |||
| 35f9329106 | |||
| 52a4bf7e5d | |||
| 97bc2526c0 | |||
| 2ab883103c | |||
| 8b8ba1219a | |||
| b2feaf17d1 | |||
| 44d9ccfda6 | |||
| 4e72dbd97c | |||
| a66e1d5e86 | |||
| f9b4ca880c | |||
| 1b09c5d10c | |||
| ce0bbe821a | |||
| 19035b1171 | |||
| fcce208278 | |||
| 26e3d39e01 | |||
| b28703465d | |||
| b9738f0b5f | |||
| 59536181da | |||
| 84705d92f6 | |||
| d10c3bc0ea | |||
| 717cfe9a0e | |||
| 5a99987e0f | |||
| bb064a4c20 | |||
| 3d5e7eec8a | |||
| fbbe74079d | |||
| fdf4835051 | |||
| 9275a4f980 | |||
| 04d1da29d4 | |||
| 94b5eb084c | |||
| 8dc1e8175a | |||
| 5217986478 | |||
| 71c1fea356 | |||
| 6f5cc4425e | |||
| 7ef1d79336 | |||
| 02692440ba | |||
| 6fc6c704ba | |||
| d6624af876 | |||
| 5fc0fbfc07 | |||
| 935185b46f | |||
| 54bf5f7737 | |||
| 1f0b373ba3 | |||
| 6d1eb2d993 |
@@ -0,0 +1,62 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# PHP files
|
||||
*.php text eol=lf
|
||||
|
||||
# XML manifests
|
||||
*.xml text eol=lf
|
||||
|
||||
# Language files
|
||||
*.ini text eol=lf
|
||||
|
||||
# SQL files
|
||||
*.sql text eol=lf
|
||||
|
||||
# Shell scripts
|
||||
*.sh text eol=lf
|
||||
|
||||
# Markdown
|
||||
*.md text eol=lf
|
||||
|
||||
# YAML
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# CSS/JS
|
||||
*.css text eol=lf
|
||||
*.js text eol=lf
|
||||
|
||||
# JSON
|
||||
*.json text eol=lf
|
||||
|
||||
# Windows scripts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Binary files
|
||||
*.zip binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
|
||||
# Export ignore (not included in archives)
|
||||
.mokogitea/ export-ignore
|
||||
.editorconfig export-ignore
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
.gitmessage export-ignore
|
||||
CLAUDE.md export-ignore
|
||||
CONTRIBUTING.md export-ignore
|
||||
CODE_OF_CONDUCT.md export-ignore
|
||||
Makefile export-ignore
|
||||
composer.json export-ignore
|
||||
phpstan.neon export-ignore
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
# ============================================================
|
||||
# Local task tracking (not version controlled)
|
||||
# ============================================================
|
||||
TODO.md
|
||||
|
||||
# ============================================================
|
||||
# Environment and secrets
|
||||
# ============================================================
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.local.php
|
||||
*.secret.php
|
||||
configuration.php
|
||||
configuration.*.php
|
||||
configuration.local.php
|
||||
conf/conf.php
|
||||
conf/conf*.php
|
||||
secrets/
|
||||
*.secrets.*
|
||||
|
||||
# ============================================================
|
||||
# Logs, dumps and databases
|
||||
# ============================================================
|
||||
*.db
|
||||
*.db-journal
|
||||
*.dump
|
||||
*.log
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
|
||||
# ============================================================
|
||||
# OS / Editor / IDE cruft
|
||||
# ============================================================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
$RECYCLE.BIN/
|
||||
System Volume Information/
|
||||
*.lnk
|
||||
Icon?
|
||||
.idea/
|
||||
.settings/
|
||||
.claude/
|
||||
.vscode/*
|
||||
!.vscode/tasks.json
|
||||
!.vscode/settings.json.example
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
*.sublime*
|
||||
.project
|
||||
.buildpath
|
||||
.classpath
|
||||
*.bak
|
||||
*.swp
|
||||
*.swo
|
||||
*.tmp
|
||||
*.old
|
||||
*.orig
|
||||
|
||||
# ============================================================
|
||||
# Dev scripts and scratch
|
||||
# ============================================================
|
||||
TODO.md
|
||||
todo*
|
||||
*ffs*
|
||||
|
||||
# ============================================================
|
||||
# SFTP / sync tools
|
||||
# ============================================================
|
||||
sftp-config*.json
|
||||
sftp-config.json.template
|
||||
sftp-settings.json
|
||||
|
||||
# ============================================================
|
||||
# Sublime SFTP / FTP sync
|
||||
# ============================================================
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
*.sublime-settings
|
||||
.libsass.json
|
||||
*.ffs*
|
||||
|
||||
# ============================================================
|
||||
# Replit / cloud IDE
|
||||
# ============================================================
|
||||
.replit
|
||||
replit.md
|
||||
|
||||
# ============================================================
|
||||
# Archives / release artifacts
|
||||
# ============================================================
|
||||
*.7z
|
||||
*.rar
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.zip
|
||||
artifacts/
|
||||
release/
|
||||
releases/
|
||||
|
||||
# ============================================================
|
||||
# Build outputs and site generators
|
||||
# ============================================================
|
||||
.mkdocs-build/
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
/site/
|
||||
*.map
|
||||
*.css.map
|
||||
*.js.map
|
||||
*.tsbuildinfo
|
||||
|
||||
# ============================================================
|
||||
# CI / test artifacts
|
||||
# ============================================================
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage/
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
junit.xml
|
||||
reports/
|
||||
test-results/
|
||||
tests/_output/
|
||||
.github/local/
|
||||
.github/workflows/*.log
|
||||
|
||||
# ============================================================
|
||||
# Node / JavaScript
|
||||
# ============================================================
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
.yarn/
|
||||
.npmrc
|
||||
.eslintcache
|
||||
package-lock.json
|
||||
|
||||
# ============================================================
|
||||
# PHP / Composer tooling
|
||||
# ============================================================
|
||||
vendor/
|
||||
!source/media/vendor/
|
||||
composer.lock
|
||||
*.phar
|
||||
codeception.phar
|
||||
.phpunit.result.cache
|
||||
.php_cs.cache
|
||||
.php-cs-fixer.cache
|
||||
.phpstan.cache
|
||||
.phplint-cache
|
||||
phpmd-cache/
|
||||
.psalm/
|
||||
.rector/
|
||||
|
||||
# ============================================================
|
||||
# Python
|
||||
# ============================================================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyc
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
.eggs/
|
||||
*.egg
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
MANIFEST
|
||||
develop-eggs/
|
||||
downloads/
|
||||
eggs/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pyright/
|
||||
.tox/
|
||||
.nox/
|
||||
*.cover
|
||||
*.coverage
|
||||
hypothesis/
|
||||
|
||||
profile.ps1
|
||||
.mcp.json
|
||||
@@ -0,0 +1,67 @@
|
||||
# MokoJoomOpenGraph
|
||||
|
||||
Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Per-article SEO with auto-generation fallback.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Package** | `pkg_mokoog` |
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
| **Wiki** | [MokoJoomOpenGraph Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/wiki) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make build # Build package ZIP
|
||||
make lint # Run linters
|
||||
make validate # Validate structure
|
||||
make release # Full release pipeline
|
||||
make clean # Clean build artifacts
|
||||
composer install # Install PHP dependencies
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Joomla **package** with three sub-extensions:
|
||||
|
||||
### com_mokoog (Component)
|
||||
- Admin backend for viewing/managing all OG tag records
|
||||
- Joomla 4/5 MVC: `Controller/DisplayController`, `Model/TagsModel`, `View/Tags/HtmlView`, `Table/TagTable`
|
||||
- Namespace: `Joomla\Component\MokoOG\Administrator`
|
||||
|
||||
### plg_system_mokoog (System Plugin)
|
||||
- Hooks `onBeforeCompileHead` to inject `<meta property="og:*">` and `<meta name="twitter:*">`
|
||||
- Auto-generates tags from article title, description, images when no custom tags exist
|
||||
- Supports articles (`com_content`), menu items, extensible content types
|
||||
|
||||
### plg_content_mokoog (Content Plugin)
|
||||
- Hooks `onContentPrepareForm` to add OG fields tab to article/menu editors
|
||||
- Hooks `onContentAfterSave`/`onContentAfterDelete` to persist/clean OG data
|
||||
|
||||
### Database Schema
|
||||
|
||||
Single table `#__mokoog_tags`:
|
||||
- `content_type` + `content_id` = unique key for any content item
|
||||
- `og_title`, `og_description`, `og_image`, `og_type` = custom OG overrides
|
||||
- `published` flag for per-item enable/disable
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Minification**: handled at build time (CI)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- PHP 8.1+ minimum
|
||||
- Joomla 4/5 DI container pattern: `services/provider.php` → Extension class
|
||||
- Legacy stub `.php` file required for plugin loader but empty
|
||||
- `SubscriberInterface` for event subscription (not `on*` method naming)
|
||||
- `bind() → check() → store()` for Table operations (not `save()`)
|
||||
- Language file placement: site (no `folder`) vs admin (`folder="administrator"`)
|
||||
- SPDX license headers on all PHP files
|
||||
@@ -0,0 +1,251 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/branch-protection.yml
|
||||
# BRIEF: Apply standardised branch protection rules to all governed repositories
|
||||
#
|
||||
# +========================================================================+
|
||||
# | BRANCH PROTECTION SETUP |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Applies protection rules for: main, dev, rc, beta, alpha |
|
||||
# | |
|
||||
# | main — Require PR, block rejected reviews, no force push |
|
||||
# | dev — Allow push, no force push, no delete |
|
||||
# | rc — Allow push, no force push, no delete |
|
||||
# | beta — Allow push, no force push, no delete |
|
||||
# | alpha — Allow push, no force push, no delete |
|
||||
# | |
|
||||
# | jmiller has override authority on all branches. |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: Branch Protection Setup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Preview mode (no changes)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
repos:
|
||||
description: 'Comma-separated repo names (empty = all governed repos)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
env:
|
||||
GITEA_URL: https://git.mokoconsulting.tech
|
||||
GITEA_ORG: MokoConsulting
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
protect:
|
||||
name: Apply Branch Protection Rules
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Determine target repos
|
||||
id: repos
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
|
||||
# Platform/standards/infra repos to exclude
|
||||
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
|
||||
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||
|
||||
if [ -n "${{ inputs.repos }}" ]; then
|
||||
# User-specified repos
|
||||
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
|
||||
else
|
||||
# Fetch all org repos
|
||||
PAGE=1
|
||||
REPOS=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
REPOS="$REPOS $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter out excluded repos
|
||||
FILTERED=""
|
||||
for REPO in $REPOS; do
|
||||
SKIP=false
|
||||
for EX in $EXCLUDE; do
|
||||
if [ "$REPO" = "$EX" ]; then
|
||||
SKIP=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$SKIP" = "false" ]; then
|
||||
FILTERED="$FILTERED $REPO"
|
||||
fi
|
||||
done
|
||||
REPOS="$FILTERED"
|
||||
fi
|
||||
|
||||
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$REPOS" | wc -w)
|
||||
echo "📋 Target repos (${COUNT}): $REPOS"
|
||||
|
||||
- name: Apply protection rules
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
REPOS="${{ steps.repos.outputs.repos }}"
|
||||
|
||||
SUCCESS=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
|
||||
# ── Rule definitions ──────────────────────────────────────
|
||||
# Only the CI bot (jmiller token) can push directly.
|
||||
# All human contributors must use PRs.
|
||||
# Force push disabled on all branches.
|
||||
|
||||
RULE_MAIN='{
|
||||
"rule_name": "main",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"dismiss_stale_approvals": true,
|
||||
"block_on_rejected_reviews": true,
|
||||
"block_on_outdated_branch": false,
|
||||
"priority": 1
|
||||
}'
|
||||
|
||||
RULE_DEV='{
|
||||
"rule_name": "dev",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 2
|
||||
}'
|
||||
|
||||
RULE_RC='{
|
||||
"rule_name": "rc",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 3
|
||||
}'
|
||||
|
||||
RULE_BETA='{
|
||||
"rule_name": "beta",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 4
|
||||
}'
|
||||
|
||||
RULE_ALPHA='{
|
||||
"rule_name": "alpha",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 5
|
||||
}'
|
||||
|
||||
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
|
||||
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
|
||||
|
||||
# ── Apply rules to each repo ──────────────────────────────
|
||||
for REPO in $REPOS; do
|
||||
echo ""
|
||||
echo "═══ ${REPO} ═══"
|
||||
|
||||
for i in "${!RULES[@]}"; do
|
||||
RULE="${RULES[$i]}"
|
||||
NAME="${RULE_NAMES[$i]}"
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY RUN] Would apply rule: ${NAME}"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Delete existing rule if present (idempotent recreate)
|
||||
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
||||
curl -sS -o /dev/null -w "" \
|
||||
-X DELETE \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
||||
|
||||
# Create rule
|
||||
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RULE" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
||||
|
||||
HTTP=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
if [ "$HTTP" = "201" ]; then
|
||||
echo " ✅ ${NAME}"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Success: ${SUCCESS}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo " ⏭️ Skipped: ${SKIPPED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "::warning::${FAILED} rule(s) failed to apply"
|
||||
fi
|
||||
@@ -1,24 +0,0 @@
|
||||
<?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>MokoOpenGraph</name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Open Graph, SEO meta tags, and social sharing image management for Joomla articles and menu items</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-23T22:16: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,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
@@ -4,36 +4,42 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# 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 |
|
||||
# | joomla: XML manifest, 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]
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -45,534 +51,344 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update RC release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Release candidate"
|
||||
|
||||
# Find the RC release and update its body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/release-candidate" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "RC release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_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 }}"}}'
|
||||
- name: Configure git for bot pushes
|
||||
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
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# -- PLATFORM DETECTION ---------------------------------------------------
|
||||
- name: Detect platform
|
||||
- name: "Detect platform"
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from manifest.xml <platform> element; fallback to generic
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*//p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
echo "Platform detected: ${PLATFORM}"
|
||||
# For packages: prefer pkg_*.xml in src/; fallback to any manifest
|
||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||
|
||||
# -- STEP 1: Read version -----------------------------------------------
|
||||
- name: "Step 1: Read version from README.md"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php /tmp/moko-platform-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'
|
||||
- name: "Determine version bump level"
|
||||
id: bump
|
||||
run: |
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
CURRENT=$(php $CLI/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||
|
||||
# Minor bump via CLI (updates README.md in-place)
|
||||
BUMP_OUT=$(php $CLI/version_bump.php --path . --minor)
|
||||
VERSION=$(php $CLI/version_read.php --path . 2>/dev/null)
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
echo "Stable bump: ${BUMP_OUT}"
|
||||
|
||||
# Set platform-specific version (Joomla XML, Dolibarr mod*.class.php)
|
||||
php $CLI/version_set_platform.php --path . --version "$VERSION" --stability stable --branch main
|
||||
|
||||
# Promote [Unreleased] in CHANGELOG.md
|
||||
php $CLI/changelog_promote.php --path . --version "$VERSION" --date "$TODAY" 2>/dev/null || true
|
||||
|
||||
# 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
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "major=${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
|
||||
|
||||
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 ;;
|
||||
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
|
||||
# Feature/dev branches: bump minor for the new stable release
|
||||
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||
case "$HEAD_REF" in
|
||||
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
|
||||
*) BUMP="minor" ;;
|
||||
esac
|
||||
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
BUMP_FLAG=""
|
||||
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
|
||||
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
|
||||
fi
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: "Read published version"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]]; then
|
||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||
echo "Published version: ${VERSION}"
|
||||
|
||||
# -- 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: >-
|
||||
- name: "Create semver tag for non-Joomla repos"
|
||||
id: semver
|
||||
if: |
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||
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' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/badge_update.php --path . --version "$VERSION"
|
||||
|
||||
# -- STEP 5: Write updates.xml (Joomla update server) ---------------------
|
||||
- name: "Step 5: Write update stream"
|
||||
id: updates
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
|
||||
# Generate updates.xml with all stability channels + suffixed versions
|
||||
# Also exports ext_element, ext_name, ext_type, ext_folder to GITHUB_OUTPUT
|
||||
php $CLI/updates_xml_build.php \
|
||||
--path . \
|
||||
--version "$VERSION" \
|
||||
--stability stable \
|
||||
--gitea-url "${GITEA_URL}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}" \
|
||||
--github-output
|
||||
|
||||
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 }}"
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
SEMVER_TAG="v${VERSION}"
|
||||
|
||||
# Reuse metadata from Step 5
|
||||
EXT_NAME="${{ steps.updates.outputs.ext_name }}"
|
||||
TYPE_PREFIX="${{ steps.updates.outputs.type_prefix }}"
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||
|
||||
RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
|
||||
NOTES=$(php $CLI/release_notes.php --path . --version "$VERSION" 2>/dev/null)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
# Create the git tag via API
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/tags" \
|
||||
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||
|
||||
php $CLI/release_manage.php \
|
||||
--action create \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--name "$RELEASE_NAME" \
|
||||
--body "## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}" \
|
||||
--target "$BRANCH" \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "$API_BASE"
|
||||
|
||||
echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 8: Build package, upload, and update checksums -------------------
|
||||
- name: "Step 8: Build package and upload"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Build ZIP + tar.gz via CLI (handles single and multi-extension packages)
|
||||
php $CLI/package_build.php --path . --version "$VERSION" --output-dir /tmp --github-output
|
||||
|
||||
# Read outputs from package_build
|
||||
ZIP_NAME="${{ steps.updates.outputs.type_prefix }}${{ steps.updates.outputs.ext_element }}-${VERSION}.zip"
|
||||
TAR_NAME="${{ steps.updates.outputs.type_prefix }}${{ steps.updates.outputs.ext_element }}-${VERSION}.tar.gz"
|
||||
|
||||
# Upload assets to release (handles dedup automatically)
|
||||
php $CLI/release_manage.php \
|
||||
--action upload \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--files "/tmp/${ZIP_NAME},/tmp/${TAR_NAME}" \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "$API_BASE"
|
||||
|
||||
# Regenerate updates.xml with SHA-256 from built package
|
||||
SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
php $CLI/updates_xml_build.php \
|
||||
--path . \
|
||||
--version "$VERSION" \
|
||||
--stability stable \
|
||||
--sha "$SHA256_ZIP" \
|
||||
--gitea-url "${GITEA_URL}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}"
|
||||
|
||||
# Commit updated updates.xml
|
||||
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 API (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"
|
||||
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Created semver tag: ${SEMVER_TAG}"
|
||||
elif [ "$HTTP_CODE" = "409" ]; then
|
||||
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||
else
|
||||
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
|
||||
# Build release body with changelog + SHA
|
||||
NOTES=$(php $CLI/release_notes.php --path . --version "$VERSION" 2>/dev/null)
|
||||
SHA256_TAR=""
|
||||
[ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n${NOTES}\n\n---\n\n### Checksums\n\n"
|
||||
BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
|
||||
BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
|
||||
[ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
|
||||
- name: Update release notes and promote changelog
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
printf '%b' "$BODY" > /tmp/release_body.md
|
||||
php $CLI/release_manage.php \
|
||||
--action update-body \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--body-file /tmp/release_body.md \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "$API_BASE"
|
||||
# Get the stable release info (version and ID)
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
|
||||
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
|
||||
VERSION=$(python3 -c "
|
||||
import json, sys, re
|
||||
r = json.load(sys.stdin)
|
||||
name = r.get('name', '')
|
||||
m = re.search(r'(\d+\.\d+\.\d+)', name)
|
||||
print(m.group(1) if m else '')
|
||||
" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
|
||||
echo "### Packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | SHA-256 |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---------|---------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
|
||||
# Update release body via API
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
|
||||
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
python3 -c "
|
||||
import sys
|
||||
version, date = sys.argv[1], sys.argv[2]
|
||||
content = open('CHANGELOG.md').read()
|
||||
old = '## [Unreleased]'
|
||||
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
|
||||
content = content.replace(old, new, 1)
|
||||
open('CHANGELOG.md', 'w').write(content)
|
||||
" "$VERSION" "$DATE"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
|
||||
git push origin main || true
|
||||
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
|
||||
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 != ''
|
||||
secrets.GH_MIRROR_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
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_TOKEN != ''
|
||||
secrets.GH_MIRROR_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 remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_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) ---------------------------------
|
||||
- name: "Delete lesser pre-release channels"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_cascade.php \
|
||||
--stability stable \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# -- STEP 11: Reset dev branch from main ------------------------------------
|
||||
- name: "Step 11: Delete and recreate dev branch from main"
|
||||
- name: "Step 11: Delete rc branch and recreate dev 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 }}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
@@ -584,30 +400,37 @@ jobs:
|
||||
"${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
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $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 != ''
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
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
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
MAIN_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Delete old version branch if it exists (same version re-release)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||
|
||||
# Create version/XX.YY.ZZ from main
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||
|
||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Post-release: Reset dev version"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Delete feature branches after PR merge
|
||||
|
||||
name: "Branch Cleanup"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Delete merged branch
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref != 'dev' &&
|
||||
github.event.pull_request.head.ref != 'main'
|
||||
|
||||
steps:
|
||||
- name: Delete source branch
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "$STATUS" = "404" ]; then
|
||||
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
@@ -1,213 +1,10 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: 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: "Universal: Cascade Main → Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||
name: "Cascade Main → Dev (DISABLED)"
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
cascade:
|
||||
name: Cascade main → branches
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
||||
|
||||
steps:
|
||||
- name: Discover target branches
|
||||
id: branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Fetch all branches (paginated)
|
||||
PAGE=1
|
||||
ALL_BRANCHES=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
|
||||
TARGETS=""
|
||||
for BRANCH in $ALL_BRANCHES; do
|
||||
case "$BRANCH" in
|
||||
dev|dev/*|rc/*|beta/*|alpha/*)
|
||||
TARGETS="$TARGETS $BRANCH"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
|
||||
|
||||
if [ -z "$TARGETS" ]; then
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "ℹ️ No cascade target branches found"
|
||||
else
|
||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$TARGETS" | wc -w)
|
||||
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
|
||||
fi
|
||||
|
||||
- name: Cascade to all target branches
|
||||
if: steps.branches.outputs.targets != ''
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TARGETS="${{ steps.branches.outputs.targets }}"
|
||||
|
||||
SUCCESS=0
|
||||
CONFLICTS=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
for BRANCH in $TARGETS; do
|
||||
echo ""
|
||||
echo "═══ main → ${BRANCH} ═══"
|
||||
|
||||
# Check if branch is already up to date
|
||||
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
|
||||
RESPONSE=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/compare/${ENCODED_BRANCH}...main")
|
||||
|
||||
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
|
||||
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo " ✅ Already up to date"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ℹ️ main is ${AHEAD} commit(s) ahead"
|
||||
|
||||
# Check for existing cascade PR
|
||||
EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
|
||||
|
||||
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
|
||||
PR_NUMBER=""
|
||||
|
||||
if [ "$EXISTING_COUNT" -gt 0 ]; then
|
||||
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
|
||||
echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
|
||||
else
|
||||
# Create cascade PR
|
||||
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
|
||||
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
|
||||
\"head\": \"main\",
|
||||
\"base\": \"${BRANCH}\"
|
||||
}" \
|
||||
"${API}/pulls")
|
||||
|
||||
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
||||
BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
||||
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
|
||||
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
|
||||
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ✅ Created PR #${PR_NUMBER}"
|
||||
fi
|
||||
|
||||
# Try auto-merge
|
||||
PR_DATA=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}")
|
||||
|
||||
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
|
||||
|
||||
if [ "$MERGEABLE" != "true" ]; then
|
||||
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"Do\": \"merge\",
|
||||
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
|
||||
\"delete_branch_after_merge\": false
|
||||
}" \
|
||||
"${API}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
|
||||
|
||||
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
||||
echo " ✅ Merged — ${BRANCH} is in sync"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
|
||||
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Merged: ${SUCCESS}"
|
||||
echo " ⚠️ Conflicts: ${CONFLICTS}"
|
||||
echo " ⏭️ Up to date: ${SKIPPED}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# 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/Template-Generic
|
||||
# PATH: /.gitea/workflows/ci-generic.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
|
||||
|
||||
name: "Generic: Project CI"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Lint & Validate ───────────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint & Validate
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect toolchain
|
||||
id: detect
|
||||
run: |
|
||||
HAS_PHP=false
|
||||
HAS_NODE=false
|
||||
[ -f "composer.json" ] && HAS_PHP=true
|
||||
[ -f "package.json" ] && HAS_NODE=true
|
||||
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
|
||||
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
|
||||
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
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
|
||||
php -v
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install PHP dependencies
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
run: |
|
||||
if [ -f "package.json" ]; then
|
||||
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
echo "::error file=${file}::PHP syntax error"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
|
||||
|
||||
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$ERRORS" -eq 0 ]; then
|
||||
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: TypeScript/JavaScript lint
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
run: |
|
||||
if [ -f "node_modules/.bin/eslint" ]; then
|
||||
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
|
||||
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
|
||||
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
|
||||
echo "::warning::ESLint config found but eslint not installed"
|
||||
else
|
||||
echo "No ESLint configured — skipping"
|
||||
fi
|
||||
|
||||
- name: TypeScript compile check
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
run: |
|
||||
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
|
||||
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
|
||||
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
|
||||
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: PHPStan static analysis
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
run: |
|
||||
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
|
||||
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect toolchain
|
||||
id: detect
|
||||
run: |
|
||||
HAS_PHP=false
|
||||
HAS_NODE=false
|
||||
[ -f "composer.json" ] && HAS_PHP=true
|
||||
[ -f "package.json" ] && HAS_NODE=true
|
||||
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
|
||||
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
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: Setup Node.js
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
|
||||
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
|
||||
|
||||
- name: Run PHP tests
|
||||
if: steps.detect.outputs.has_php == 'true'
|
||||
run: |
|
||||
if [ -f "vendor/bin/phpunit" ]; then
|
||||
vendor/bin/phpunit --testdox 2>&1
|
||||
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
|
||||
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
|
||||
echo "::warning::PHPUnit config found but phpunit not installed"
|
||||
else
|
||||
echo "No PHPUnit configured — skipping"
|
||||
fi
|
||||
|
||||
- name: Run Node.js tests
|
||||
if: steps.detect.outputs.has_node == 'true'
|
||||
run: |
|
||||
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
|
||||
npm test 2>&1
|
||||
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "No test script in package.json — skipping"
|
||||
fi
|
||||
|
||||
- name: Build check
|
||||
run: |
|
||||
if [ -f "Makefile" ]; then
|
||||
make build 2>&1 || echo "::warning::Build failed or not configured"
|
||||
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
|
||||
npm run build 2>&1 || echo "::warning::Build failed"
|
||||
fi
|
||||
@@ -35,25 +35,32 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
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 php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
php -v && composer --version
|
||||
|
||||
- name: Clone MokoStandards
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_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
|
||||
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
|
||||
echo "mokocli already available on runner — skipping clone"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
|
||||
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
@@ -124,8 +131,8 @@ jobs:
|
||||
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
|
||||
# Check required tags: name, version, author
|
||||
for TAG in name version author; do
|
||||
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
|
||||
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
@@ -133,6 +140,19 @@ jobs:
|
||||
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
done
|
||||
|
||||
# Namespace is required for components/plugins but not packages
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||
if [ "$EXT_TYPE" != "package" ]; then
|
||||
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
|
||||
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
echo "Package extension — \`<namespace>\` not required." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
@@ -225,14 +245,417 @@ jobs:
|
||||
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Check config.xml and access.xml for components
|
||||
run: |
|
||||
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find all component manifests (XML with type="component")
|
||||
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
|
||||
|
||||
if [ -z "$COMP_MANIFESTS" ]; then
|
||||
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for MANIFEST in $COMP_MANIFESTS; do
|
||||
COMP_DIR=$(dirname "$MANIFEST")
|
||||
COMP_NAME=$(basename "$COMP_DIR")
|
||||
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check access.xml exists
|
||||
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ACCESS_FILE" ]; then
|
||||
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
for ACTION in core.admin core.manage; do
|
||||
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
|
||||
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check config.xml exists
|
||||
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$CONFIG_FILE" ]; then
|
||||
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SQL schema validation
|
||||
run: |
|
||||
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find SQL files in source/htdocs
|
||||
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$SQL_FILES" ]; then
|
||||
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for FILE in $SQL_FILES; do
|
||||
# Basic syntax check: balanced parentheses, no empty files
|
||||
SIZE=$(wc -c < "$FILE" | tr -d ' ')
|
||||
if [ "$SIZE" -eq 0 ]; then
|
||||
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check for common SQL errors
|
||||
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
|
||||
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
|
||||
# Check update SQL files follow version numbering pattern
|
||||
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -n "$UPDATE_DIR" ]; then
|
||||
BAD_NAMES=0
|
||||
for UFILE in "$UPDATE_DIR"/*.sql; do
|
||||
[ ! -f "$UFILE" ] && continue
|
||||
BASENAME=$(basename "$UFILE" .sql)
|
||||
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
|
||||
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
|
||||
BAD_NAMES=$((BAD_NAMES + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$BAD_NAMES" -gt 0 ]; then
|
||||
ERRORS=$((ERRORS + BAD_NAMES))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Manifest file references check
|
||||
run: |
|
||||
echo "### Manifest File References" >> $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 [ -z "$MANIFEST" ]; then
|
||||
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check <filename> references
|
||||
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FILENAMES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check <folder> references
|
||||
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FOLDERS; do
|
||||
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Check <file> references in package manifests (ZIP files won't exist in source)
|
||||
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||
if [ "$EXT_TYPE" != "package" ]; then
|
||||
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||
for F in $FILES; do
|
||||
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
|
||||
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Form XML validation
|
||||
run: |
|
||||
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$FORM_FILES" ]; then
|
||||
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
for FILE in $FORM_FILES; do
|
||||
if command -v php &> /dev/null; then
|
||||
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
# Check for valid Joomla form structure
|
||||
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Deprecated Joomla API check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
SRC_DIR=""
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Joomla 3/4 deprecated patterns that break in Joomla 6
|
||||
PATTERNS=(
|
||||
'JFactory::'
|
||||
'JText::'
|
||||
'JHtml::'
|
||||
'JRoute::'
|
||||
'JUri::'
|
||||
'JLog::'
|
||||
'JTable::'
|
||||
'JInput'
|
||||
'CMSFactory::\$application'
|
||||
'JApplicationCms'
|
||||
)
|
||||
|
||||
for PATTERN in "${PATTERNS[@]}"; do
|
||||
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
|
||||
if [ -n "$HITS" ]; then
|
||||
COUNT=$(echo "$HITS" | wc -l)
|
||||
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + COUNT))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Template output escaping check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=0
|
||||
|
||||
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$TMPL_FILES" ]; then
|
||||
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for FILE in $TMPL_FILES; do
|
||||
# Check for unescaped output: <?= $var ?> or echo $var without escape()
|
||||
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$UNESCAPED" ]; then
|
||||
HITS=$(echo "$UNESCAPED" | wc -l)
|
||||
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + HITS))
|
||||
fi
|
||||
|
||||
# Check for echo without escaping in template context
|
||||
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||
if [ -n "$RAW_ECHO" ]; then
|
||||
HITS=$(echo "$RAW_ECHO" | wc -l)
|
||||
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
|
||||
WARNINGS=$((WARNINGS + HITS))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Namespace consistency check
|
||||
run: |
|
||||
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
# Find component/plugin manifests with <namespace> tags
|
||||
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
|
||||
|
||||
if [ -z "$MANIFESTS" ]; then
|
||||
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for MANIFEST in $MANIFESTS; do
|
||||
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$NS_PATH" ] && continue
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check PHP files have matching namespace
|
||||
while IFS= read -r -d '' PHP_FILE; do
|
||||
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
|
||||
[ -z "$FILE_NS" ] && continue
|
||||
|
||||
# Namespace should start with the manifest namespace path
|
||||
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
|
||||
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SPDX license header check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=0
|
||||
|
||||
SRC_DIR=""
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
TOTAL=0
|
||||
while IFS= read -r -d '' FILE; do
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if ! head -10 "$FILE" | grep -qi "SPDX"; then
|
||||
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Service provider check
|
||||
run: |
|
||||
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=0
|
||||
|
||||
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||
if [ -z "$PROVIDERS" ]; then
|
||||
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
for FILE in $PROVIDERS; do
|
||||
# Must return a ServiceProviderInterface
|
||||
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Must have return statement
|
||||
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
|
||||
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
else
|
||||
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
release-readiness:
|
||||
name: Release Readiness Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate release readiness
|
||||
run: |
|
||||
@@ -338,15 +761,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP ${{ matrix.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 composer >/dev/null 2>&1
|
||||
fi
|
||||
php -v && composer --version
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install \
|
||||
@@ -384,14 +811,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
run: php -v && composer --version
|
||||
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 composer >/dev/null 2>&1
|
||||
fi
|
||||
php -v && composer --version
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
if [ -f "composer.json" ]; then
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
@@ -448,3 +880,24 @@ jobs:
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
exit $EXIT
|
||||
|
||||
pre-release:
|
||||
name: Build RC Pre-Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-validate, test]
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Trigger pre-release build
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
"${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: "Publish to Composer"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '[0-9]*.[0-9]*.[0-9]*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Package
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip publish]')
|
||||
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 php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-dev --no-interaction --prefer-dist --quiet
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Package version: ${VERSION}"
|
||||
|
||||
# Gitea Composer Registry — auto-publishes from tags
|
||||
# The tag push itself registers the package at:
|
||||
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
|
||||
- name: Verify Gitea registry
|
||||
run: |
|
||||
echo "Gitea Composer registry auto-publishes from tags."
|
||||
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
|
||||
echo "Install: composer require mokoconsulting/mokocli"
|
||||
|
||||
# Packagist — notify of new version
|
||||
- name: Notify Packagist
|
||||
if: secrets.PACKAGIST_TOKEN != ''
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Notifying Packagist of version ${VERSION}..."
|
||||
curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
|
||||
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
|
||||
&& echo "Packagist notified" \
|
||||
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -25,10 +25,6 @@
|
||||
name: "Universal: Secret Scanning"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- 'dev/**'
|
||||
schedule:
|
||||
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.03.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
create-branch:
|
||||
name: Create feature branch
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
# Build slug from title: lowercase, replace non-alnum with dash, trim
|
||||
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
|
||||
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
|
||||
|
||||
# Check dev branch exists
|
||||
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/branches/dev" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${DEV_EXISTS}" != "200" ]; then
|
||||
echo "No dev branch -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create branch from dev
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/branches" \
|
||||
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${HTTP}" = "201" ]; then
|
||||
echo "Created branch: ${BRANCH}"
|
||||
|
||||
# Comment on issue with branch link
|
||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/comments" \
|
||||
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
|
||||
|
||||
echo "Commented on issue #${ISSUE_NUM}"
|
||||
else
|
||||
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
|
||||
fi
|
||||
@@ -18,7 +18,6 @@ on:
|
||||
- "Joomla Build & Release"
|
||||
- "Joomla Extension CI"
|
||||
- "Deploy"
|
||||
- "Cascade Main → Dev"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# 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
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
@@ -52,22 +52,22 @@ jobs:
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
alpha/*|beta/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Pre-release branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc/*)
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
@@ -96,6 +96,32 @@ jobs:
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Secret Scanning ──────────────────────────────────────────────────
|
||||
gitleaks:
|
||||
name: 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
|
||||
|
||||
- name: Scan PR commits for secrets
|
||||
run: |
|
||||
if gitleaks detect --source . --verbose \
|
||||
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
|
||||
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::error::Potential secrets detected in PR commits"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
@@ -105,10 +131,25 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found in source files"
|
||||
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]')
|
||||
# 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"
|
||||
|
||||
@@ -132,6 +173,98 @@ jobs:
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Joomla JEXEC guard check
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
# Skip vendor, node_modules, and index.html stub files
|
||||
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
||||
# Check first 10 lines for JEXEC or JPATH guard
|
||||
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
|
||||
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
||||
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "JEXEC guard: OK"
|
||||
|
||||
- name: Joomla directory listing protection
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
MISSING=0
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
while IFS= read -r dir; do
|
||||
if [ ! -f "${dir}/index.html" ]; then
|
||||
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Directory protection: ${MISSING} missing (advisory)"
|
||||
|
||||
- name: Joomla script file and asset checks
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check scriptfile exists if declared
|
||||
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
if [ -n "$SCRIPTFILE" ]; then
|
||||
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Require joomla.asset.json and validate it
|
||||
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ASSET_JSON" ]; then
|
||||
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
|
||||
echo "::error::joomla.asset.json is not valid JSON"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
}
|
||||
fi
|
||||
echo "joomla.asset.json: valid"
|
||||
fi
|
||||
|
||||
# Validate all XML files in src/ are well-formed
|
||||
XML_ERRORS=0
|
||||
if command -v php &> /dev/null; then
|
||||
while IFS= read -r -d '' xmlfile; do
|
||||
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
|
||||
XML_ERRORS=$((XML_ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
|
||||
fi
|
||||
if [ "$XML_ERRORS" -gt 0 ]; then
|
||||
echo "::error::${XML_ERRORS} XML file(s) are malformed"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "XML well-formedness: OK"
|
||||
fi
|
||||
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
echo "Joomla asset checks: OK"
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
@@ -149,6 +282,13 @@ jobs:
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
# Block legacy raw/branch update server URLs on MokoGitea
|
||||
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||
if [ -n "$RAW_URLS" ]; then
|
||||
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||
echo "$RAW_URLS"
|
||||
exit 1
|
||||
fi
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
@@ -181,6 +321,160 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Validate Joomla language files
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Require both en-GB and en-US language directories
|
||||
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$LANG_ROOT" ]; then
|
||||
echo "No language/ directory found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$LANG_ROOT/en-GB" ]; then
|
||||
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
if [ ! -d "$LANG_ROOT/en-US" ]; then
|
||||
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check that en-GB and en-US have matching .ini files
|
||||
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
|
||||
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
|
||||
[ ! -f "$GB_INI" ] && continue
|
||||
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
|
||||
if [ ! -f "$US_INI" ]; then
|
||||
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
|
||||
[ ! -f "$US_INI" ] && continue
|
||||
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
|
||||
if [ ! -f "$GB_INI" ]; then
|
||||
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Find all .ini language files
|
||||
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -z "$INI_FILES" ]; then
|
||||
echo "No .ini language files found"
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
|
||||
|
||||
for FILE in $INI_FILES; do
|
||||
FNAME=$(basename "$FILE")
|
||||
LINENUM=0
|
||||
SEEN_KEYS=""
|
||||
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
LINENUM=$((LINENUM + 1))
|
||||
|
||||
# Skip empty lines and comments
|
||||
[ -z "$line" ] && continue
|
||||
echo "$line" | grep -qE '^\s*;' && continue
|
||||
echo "$line" | grep -qE '^\s*$' && continue
|
||||
|
||||
# Must match KEY="VALUE" format
|
||||
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract key and check for duplicates
|
||||
KEY=$(echo "$line" | sed 's/=.*//')
|
||||
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
SEEN_KEYS="${SEEN_KEYS}
|
||||
${KEY}"
|
||||
done < "$FILE"
|
||||
|
||||
echo " ${FILE}: checked ${LINENUM} lines"
|
||||
done
|
||||
|
||||
# Cross-check en-GB vs en-US key consistency
|
||||
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
|
||||
for GB_FILE in "$GB_DIR"/*.ini; do
|
||||
[ ! -f "$GB_FILE" ] && continue
|
||||
FNAME=$(basename "$GB_FILE")
|
||||
US_FILE="$US_DIR/$FNAME"
|
||||
[ ! -f "$US_FILE" ] && continue
|
||||
|
||||
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
|
||||
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
|
||||
|
||||
# Keys in en-GB but not en-US
|
||||
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_US" ]; then
|
||||
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
|
||||
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
# Keys in en-US but not en-GB
|
||||
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_GB" ]; then
|
||||
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
|
||||
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
{
|
||||
echo "### Language File Validation"
|
||||
echo "| Metric | Count |"
|
||||
echo "|---|---|"
|
||||
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
|
||||
echo "| Errors | ${ERRORS} |"
|
||||
echo "| Warnings | ${WARNINGS} |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::Language validation failed with ${ERRORS} error(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Language files: OK (${WARNINGS} warning(s))"
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
@@ -192,3 +486,49 @@ jobs:
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||
report-issues:
|
||||
name: Report Issues
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.validate.result == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issue for PR validation failure"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
./automation/ci-issue-reporter.sh \
|
||||
--gate "PR Validation" \
|
||||
--workflow "PR Check" \
|
||||
--severity error \
|
||||
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||
|
||||
@@ -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: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
|
||||
|
||||
name: "Joomla: Metadata Validation"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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:
|
||||
validate-metadata:
|
||||
name: "Validate Joomla Metadata"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Validate metadata against Joomla manifest
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
php ${MOKO_CLI}/joomla_metadata_validate.php \
|
||||
--path . \
|
||||
--token "${GITEA_TOKEN}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}" \
|
||||
--api-base "${GITEA_URL}/api/v1" \
|
||||
--ci
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
|
||||
exit 1
|
||||
fi
|
||||
@@ -4,15 +4,26 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
|
||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- 'fix/**'
|
||||
- 'patch/**'
|
||||
- 'hotfix/**'
|
||||
- 'bugfix/**'
|
||||
- 'chore/**'
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
@@ -35,44 +46,74 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_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
|
||||
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
|
||||
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/mokocli
|
||||
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .gitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
# Auto-detect and update platform if not set in manifest
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Check platform eligibility (Joomla only)
|
||||
id: eligibility
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||
fi
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
case "${{ github.ref_name }}" in
|
||||
rc) STABILITY="release-candidate" ;;
|
||||
alpha) STABILITY="alpha" ;;
|
||||
beta) STABILITY="beta" ;;
|
||||
*) STABILITY="development" ;;
|
||||
esac
|
||||
else
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
fi
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
@@ -81,145 +122,147 @@ jobs:
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Bump patch version via CLI
|
||||
CURRENT=$(php $CLI/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$CURRENT" ] && CURRENT="00.00.00"
|
||||
php $CLI/version_bump.php --path .
|
||||
VERSION=$(php $CLI/version_read.php --path . 2>/dev/null)
|
||||
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
|
||||
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||
case "$STABILITY" in
|
||||
release-candidate) BUMP="minor" ;;
|
||||
*) BUMP="patch" ;;
|
||||
esac
|
||||
|
||||
# Set platform-specific version with stability suffix
|
||||
php $CLI/version_set_platform.php \
|
||||
--path . --version "$VERSION" --stability "$STABILITY" --branch "${{ github.ref_name }}"
|
||||
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
|
||||
|
||||
# Set stability suffix and verify consistency
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
|
||||
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Append suffix for output
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): bump ${CURRENT} → ${VERSION}${SUFFIX} [skip ci]"
|
||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element via manifest_element.php
|
||||
php ${MOKO_CLI}/manifest_element.php \
|
||||
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||
--repo "${GITEA_REPO}" --github-output
|
||||
|
||||
# Read back element outputs
|
||||
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build package
|
||||
id: package
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SUFFIX="${{ steps.meta.outputs.suffix }}"
|
||||
|
||||
# Build ZIP + tar.gz via CLI (handles type prefix, excludes, multi-extension packages)
|
||||
php $CLI/package_build.php \
|
||||
--path . \
|
||||
--version "${VERSION}${SUFFIX}" \
|
||||
--output-dir build \
|
||||
--github-output
|
||||
|
||||
- name: Create release and upload
|
||||
run: |
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SUFFIX="${{ steps.meta.outputs.suffix }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
EXT_ELEMENT="${{ steps.package.outputs.ext_element }}"
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
ZIP_PATH="${{ steps.package.outputs.zip_path }}"
|
||||
TAR_PATH="${{ steps.package.outputs.tar_path }}"
|
||||
|
||||
# Create release
|
||||
php $CLI/release_manage.php \
|
||||
--action create \
|
||||
--tag "$TAG" \
|
||||
--name "${EXT_ELEMENT} ${VERSION}${SUFFIX} (${STABILITY})" \
|
||||
--body "## ${VERSION}${SUFFIX} ($(date +%Y-%m-%d))\n**Channel:** ${STABILITY}\n**SHA-256:** \`${SHA256}\`" \
|
||||
--target "${{ github.ref_name }}" \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "$API_BASE"
|
||||
|
||||
# Upload assets
|
||||
FILES="${ZIP_PATH}"
|
||||
[ -f "$TAR_PATH" ] && FILES="${FILES},${TAR_PATH}"
|
||||
php $CLI/release_manage.php \
|
||||
--action upload \
|
||||
--tag "$TAG" \
|
||||
--files "$FILES" \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "$API_BASE"
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
# Map stability names
|
||||
case "$STABILITY" in
|
||||
release-candidate) CLI_STABILITY="rc" ;;
|
||||
*) CLI_STABILITY="$STABILITY" ;;
|
||||
esac
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Generate updates.xml with stability-suffixed versions
|
||||
php $CLI/updates_xml_build.php \
|
||||
--path . \
|
||||
--version "$VERSION" \
|
||||
--stability "$CLI_STABILITY" \
|
||||
--sha "$SHA256" \
|
||||
--gitea-url "${GITEA_URL}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}"
|
||||
|
||||
# Commit and push
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git add updates.xml
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
else
|
||||
NOTES="Release ${VERSION}"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
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
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
# updates.xml is generated dynamically by MokoGitea license server
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
if: steps.eligibility.outputs.proceed == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
--token "${TOKEN}" \
|
||||
--api-base "${API_BASE}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
|
||||
# Map workflow stability names to CLI names
|
||||
case "$STABILITY" in
|
||||
release-candidate) CLI_STABILITY="rc" ;;
|
||||
*) CLI_STABILITY="$STABILITY" ;;
|
||||
esac
|
||||
|
||||
php /tmp/moko-platform-api/cli/release_cascade.php \
|
||||
--stability "$CLI_STABILITY" \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/rc-revert.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||
|
||||
name: "RC Revert"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
revert:
|
||||
name: Rename rc/ back to dev/
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == false &&
|
||||
startsWith(github.event.pull_request.head.ref, 'rc/')
|
||||
|
||||
steps:
|
||||
- name: Rename branch
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
SUFFIX="${BRANCH#rc/}"
|
||||
DEV_BRANCH="dev/${SUFFIX}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Create dev/ branch from rc/ branch
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||
"${API}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "201" ]; then
|
||||
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Delete rc/ branch
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
|
||||
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -7,18 +7,14 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# INGROUP: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# 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.
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Joomla: Repo Health"
|
||||
|
||||
concurrency:
|
||||
group: repo-health-${{ github.repository }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
name: "Generic: Repo Health"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -28,32 +24,28 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
description: 'Validation profile: all, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
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_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/
|
||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||
REPO_DISALLOWED_DIRS:
|
||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||
@@ -64,7 +56,7 @@ env:
|
||||
# File / directory variables
|
||||
DOCS_INDEX: docs/docs-index.md
|
||||
SCRIPT_DIR: scripts
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
WORKFLOWS_DIR: .mokogitea/workflows
|
||||
SHELLCHECK_PATTERN: '*.sh'
|
||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -85,7 +77,7 @@ jobs:
|
||||
- name: Check actor permission (admin only)
|
||||
id: perm
|
||||
env:
|
||||
TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
@@ -142,101 +134,6 @@ jobs:
|
||||
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
|
||||
@@ -260,14 +157,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
if [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -288,7 +185,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
|
||||
if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi
|
||||
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
|
||||
|
||||
missing_dirs=()
|
||||
@@ -374,14 +271,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
if [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -392,23 +289,27 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid)
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid for extension repos)
|
||||
SOURCE_DIR=""
|
||||
if [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||
# Platform/tooling repos don't need src/
|
||||
SOURCE_DIR=""
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
if printf '%s' "${item}" | grep -q '/$'; then
|
||||
d="${item%/}"
|
||||
@@ -450,12 +351,8 @@ jobs:
|
||||
fi
|
||||
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
||||
|
||||
if [ "${#dev_paths[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev/* branch (e.g. dev/01.00.00)")
|
||||
fi
|
||||
|
||||
if [ "${#dev_branches[@]}" -gt 0 ]; then
|
||||
missing_required+=("invalid branch dev (must be dev/<version>)")
|
||||
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev or dev/* branch")
|
||||
fi
|
||||
|
||||
content_warnings=()
|
||||
@@ -481,26 +378,7 @@ jobs:
|
||||
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
|
||||
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
|
||||
|
||||
report_json="$(python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
profile = os.environ.get('PROFILE_RAW') or 'all'
|
||||
|
||||
missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
|
||||
missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
|
||||
content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
|
||||
|
||||
out = {
|
||||
'profile': profile,
|
||||
'missing_required': [x for x in missing_required if x],
|
||||
'missing_optional': [x for x in missing_optional if x],
|
||||
'content_warnings': [x for x in content_warnings if x],
|
||||
}
|
||||
|
||||
print(json.dumps(out, indent=2))
|
||||
PY
|
||||
)"
|
||||
report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
@@ -578,12 +456,14 @@ jobs:
|
||||
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
|
||||
fi
|
||||
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
if [ -n "${SOURCE_DIR}" ]; then
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#joomla_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
@@ -629,43 +509,29 @@ jobs:
|
||||
fi
|
||||
|
||||
if [ -f "${DOCS_INDEX}" ]; then
|
||||
missing_links="$(python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
|
||||
idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
|
||||
base = os.getcwd()
|
||||
|
||||
bad = []
|
||||
pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
|
||||
|
||||
with open(idx, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
for m in pat.findall(line):
|
||||
link = m.strip()
|
||||
if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
|
||||
continue
|
||||
if link.startswith('/'):
|
||||
rel = link.lstrip('/')
|
||||
else:
|
||||
rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
|
||||
rel = rel.split('#', 1)[0]
|
||||
rel = rel.split('?', 1)[0]
|
||||
if not rel:
|
||||
continue
|
||||
p = os.path.join(base, rel)
|
||||
if not os.path.exists(p):
|
||||
bad.append(rel)
|
||||
|
||||
print('\n'.join(sorted(set(bad))))
|
||||
PY
|
||||
)"
|
||||
missing_links=""
|
||||
while IFS= read -r docline; do
|
||||
for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
|
||||
case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
|
||||
linkpath="${link%%#*}"
|
||||
linkpath="${linkpath%%\?*}"
|
||||
[ -z "$linkpath" ] && continue
|
||||
if [ "${linkpath:0:1}" = "/" ]; then
|
||||
testpath="${linkpath#/}"
|
||||
else
|
||||
testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
|
||||
fi
|
||||
[ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
|
||||
done
|
||||
done < "${DOCS_INDEX}"
|
||||
if [ -n "${missing_links}" ]; then
|
||||
extended_findings+=("docs/docs-index.md contains broken relative links")
|
||||
{
|
||||
printf '%s\n' '### Docs index link integrity'
|
||||
printf '%s\n' 'Broken relative links:'
|
||||
while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
|
||||
for bl in ${missing_links}; do
|
||||
printf '%s\n' "- ${bl}"
|
||||
done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
@@ -739,7 +605,7 @@ jobs:
|
||||
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' '| Release policy | N/A | Releases handled by MokoGitea |'
|
||||
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 |'
|
||||
@@ -764,3 +630,83 @@ jobs:
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
|
||||
site-health:
|
||||
name: Site Health
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
|
||||
- name: Uptime check
|
||||
if: env.URLS != ''
|
||||
run: |
|
||||
echo "$URLS" > /tmp/urls.txt
|
||||
php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down"
|
||||
rm -f /tmp/urls.txt
|
||||
env:
|
||||
URLS: ${{ vars.MONITORED_URLS }}
|
||||
|
||||
- name: SSL certificate check
|
||||
if: env.DOMAINS != ''
|
||||
run: |
|
||||
echo "$DOMAINS" > /tmp/domains.txt
|
||||
php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon"
|
||||
rm -f /tmp/domains.txt
|
||||
env:
|
||||
DOMAINS: ${{ vars.MONITORED_DOMAINS }}
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Issue Reporter — file issues for failed gates
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, scripts_governance, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issues for failed gates"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
REPORTER="./automation/ci-issue-reporter.sh"
|
||||
WF="Repo Health"
|
||||
|
||||
report_gate() {
|
||||
local gate="$1" result="$2" details="$3"
|
||||
if [ "$result" = "failure" ]; then
|
||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
|
||||
@@ -4,20 +4,18 @@
|
||||
#
|
||||
# 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
|
||||
# INGROUP: moko-platform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/update-server.yml
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
|
||||
#
|
||||
# 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/**
|
||||
# Thin wrapper around moko-platform CLI tools.
|
||||
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
|
||||
#
|
||||
# Joomla filters by user's "Minimum Stability" setting.
|
||||
# Joomla filters update entries by the user's "Minimum Stability" setting.
|
||||
|
||||
name: "Joomla: Update Server"
|
||||
name: "Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -66,55 +64,60 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update updates.xml
|
||||
name: Update Server
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/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
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || true
|
||||
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate updates.xml entry
|
||||
id: update
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve stability and bump version
|
||||
id: meta
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
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)
|
||||
# Configure git for bot pushes
|
||||
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
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
# Determine stability from branch or input
|
||||
# Auto-bump patch version
|
||||
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
|
||||
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Strip any existing suffix before applying stability
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
# Determine stability from branch or manual input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
@@ -123,277 +126,122 @@ jobs:
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
|
||||
else
|
||||
STABILITY="development"
|
||||
else
|
||||
STABILITY="stable"
|
||||
fi
|
||||
|
||||
# Version suffix per stability stream
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
rc) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
*) SUFFIX=""; TAG="stable" ;;
|
||||
esac
|
||||
|
||||
# Propagate version with stability suffix to all manifest files
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Re-read version (now includes suffix from version_set_platform)
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "display_version=${VERSION}" >> "$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
|
||||
# Commit version bump if changed
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
|
||||
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
# -- Sync updates.xml to main (for non-main branches) ----------------------
|
||||
- name: Create release and upload package
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Create or update Gitea release
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
# Build package and upload
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
# Commit and push updates.xml
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main'
|
||||
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||
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
|
||||
python3 -c "
|
||||
import base64, json, urllib.request, sys
|
||||
with open('updates.xml', 'rb') as f:
|
||||
content = base64.b64encode(f.read()).decode()
|
||||
payload = json.dumps({
|
||||
'content': content,
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
|
||||
'branch': 'main'
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/contents/updates.xml',
|
||||
data=payload, method='PUT',
|
||||
headers={
|
||||
'Authorization': 'token ${GITEA_TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
print('updates.xml synced to main')
|
||||
except Exception as e:
|
||||
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
|
||||
"
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
@@ -407,12 +255,11 @@ jobs:
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# -- Permission check: admin or maintain role required --------
|
||||
# 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 }}" \
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
@@ -442,11 +289,11 @@ jobs:
|
||||
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
|
||||
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -454,11 +301,12 @@ jobs:
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
DISPLAY="${{ steps.meta.outputs.display_version }}"
|
||||
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
|
||||
# VERSION: 01.01.00
|
||||
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
||||
|
||||
name: "Universal: Workflow Sync Trigger"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync workflows to live repos
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
!contains(github.event.pull_request.title, '[skip sync]')
|
||||
|
||||
steps:
|
||||
- name: Determine platform from repo name
|
||||
id: platform
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
case "$REPO" in
|
||||
Template-Joomla) PLATFORM="joomla" ;;
|
||||
Template-Dolibarr) PLATFORM="dolibarr" ;;
|
||||
Template-Go) PLATFORM="go" ;;
|
||||
Template-MCP) PLATFORM="mcp" ;;
|
||||
Template-Generic) PLATFORM="" ;;
|
||||
*) PLATFORM="" ;;
|
||||
esac
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
echo "Platform: ${PLATFORM:-all}"
|
||||
|
||||
- name: Clone mokocli
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
|
||||
- name: Run workflow sync
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
ARGS="--token ${MOKOGITEA_TOKEN}"
|
||||
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
|
||||
ARGS="${ARGS} --phase repos"
|
||||
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ -n "$PLATFORM" ]; then
|
||||
ARGS="${ARGS} --platform-filter ${PLATFORM}"
|
||||
fi
|
||||
|
||||
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
|
||||
+55
-15
@@ -1,21 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
<!-- VERSION: 01.00.00 -->
|
||||
|
||||
All notable changes to MokoOpenGraph will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [01.03.00] --- 2026-06-21
|
||||
|
||||
|
||||
<!-- VERSION: 01.03.00 -->
|
||||
|
||||
All notable changes to MokoSuiteOpenGraph will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [01.03.00] --- 2026-06-21
|
||||
|
||||
### Security
|
||||
- Fix JSON-LD XSS vulnerability via `</script>` injection in content data (#34)
|
||||
- Add ACL permission checks to Batch and ImportExport controllers (#37)
|
||||
- Add CSV import file type, MIME type, size, and content_type validation (#35)
|
||||
- Fix multilingual data corruption in content plugin load/save (#41)
|
||||
|
||||
### Added
|
||||
- Initial package structure with component, system plugin, and content plugin
|
||||
- Open Graph meta tag injection via system plugin (`onBeforeCompileHead`)
|
||||
- Twitter/X Card meta tag support (Summary and Summary with Large Image)
|
||||
- Per-article OG fields in the article editor
|
||||
- Per-menu-item OG fields in the menu item editor
|
||||
- Auto-generation of OG tags from article title, description, and images
|
||||
- Site-wide default OG title and description plugin parameters
|
||||
- Discord embed color via `theme-color` meta tag (color picker in plugin config)
|
||||
- LinkedIn article tags: `article:published_time`, `article:modified_time`, `article:author`
|
||||
- `og:image:width` and `og:image:height` for faster social preview rendering
|
||||
- `onMokoOGAfterRender` event for third-party plugin extensibility
|
||||
- Joomla Web Services API for OG tags — full CRUD at `/api/v1/mokoog/tags` (#27)
|
||||
- Live social preview in article/menu editors (Facebook and Twitter/X card mockups) (#3)
|
||||
- CSV import/export for bulk OG tag management (#12)
|
||||
- OG image text overlay generator (#7)
|
||||
- Multilingual OG tag support with per-language records (#11)
|
||||
- JSON-LD structured data: Article, Product, WebPage, BreadcrumbList schemas (#6)
|
||||
- Social platform debugger quick links (Facebook, LinkedIn, Google) (#9)
|
||||
- MokoSuiteShop product OG tag support with pricing meta and JSON-LD Product schema (#53)
|
||||
- WhatsApp and Telegram link preview optimization (#10)
|
||||
- Category-level OG tag support (#4)
|
||||
- Batch OG tag generation for existing articles (#1)
|
||||
- Auto-resize OG images to 1200x630px with center crop (#2)
|
||||
- SEO meta tag management: title, description, robots, canonical URL (#8)
|
||||
- Per-article and per-menu-item OG fields in the editor
|
||||
- Auto-generation of OG tags from article content, title, and images
|
||||
- Default fallback image configuration
|
||||
- Admin tag manager component for viewing all OG records
|
||||
- Facebook App ID support
|
||||
- Database table `#__mokoog_tags` for storing custom OG data
|
||||
- Admin tag manager component with filtering, search, and pagination
|
||||
- Facebook App ID and Telegram channel support
|
||||
- Database table `#__mokoog_tags` with multilingual unique key
|
||||
|
||||
### Changed
|
||||
- Consolidated article DB queries into single cached lookup — 5 queries reduced to 1 (#38)
|
||||
- Dynamic `og:image:width`/`og:image:height` from actual image dimensions instead of hardcoded (#39)
|
||||
- Replace GD `@` error suppression with `Log::add()` warnings (#49)
|
||||
- TagTable::check() validates og_type, field lengths, canonical_url, robots directives (#43)
|
||||
- CSV import/export now includes language column for multilingual support (#52)
|
||||
- Batch process limit capped at 200 per request (#42)
|
||||
- Canonical URL replacement uses public `getHeadData()`/`setHeadData()` API (#39)
|
||||
- Language-aware queries on `loadOgDataByType()` and `loadOgDataByMenu()` (#47)
|
||||
|
||||
### Removed
|
||||
- Removed dead ContentType adapters (K2, VirtueMart, HikaShop) — not targeting these platforms (#36)
|
||||
- Removed `<updateservers>` from package manifest — managed externally (#44)
|
||||
- Removed deploy-manual.yml workflow
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MokoOpenGraph** -- Open Graph, Twitter Card, and social sharing meta tag management for Joomla
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Platform** | joomla |
|
||||
| **Language** | PHP |
|
||||
| **Default branch** | main |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Wiki** | [MokoOpenGraph Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/wiki) |
|
||||
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
make build # Build the project
|
||||
make lint # Run linters
|
||||
make validate # Validate structure
|
||||
make release # Full release pipeline
|
||||
make minify # Minify CSS/JS assets
|
||||
make clean # Clean build artifacts
|
||||
```
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Joomla **package** extension (`pkg_mokoog`) containing three sub-extensions:
|
||||
|
||||
### com_mokoog (Component)
|
||||
- Admin backend for viewing and managing all OG tag records
|
||||
- Joomla 4/5 MVC: `Controller/DisplayController`, `Model/TagsModel`, `View/Tags/HtmlView`, `Table/TagTable`
|
||||
- Namespace: `Joomla\Component\MokoOG\Administrator`
|
||||
- Database table: `#__mokoog_tags` — stores custom OG data per content item
|
||||
|
||||
### plg_system_mokoog (System Plugin)
|
||||
- Hooks `onBeforeCompileHead` to inject `<meta property="og:*">` and `<meta name="twitter:*">` tags
|
||||
- Auto-generates tags from article title, description, and images when no custom tags exist
|
||||
- Supports articles (`com_content`), menu items, and extensible content types
|
||||
- Namespace: `Joomla\Plugin\System\MokoOG`
|
||||
|
||||
### plg_content_mokoog (Content Plugin)
|
||||
- Hooks `onContentPrepareForm` to add OG fields tab to article and menu item editors
|
||||
- Hooks `onContentAfterSave` / `onContentAfterDelete` to persist/clean OG data
|
||||
- Namespace: `Joomla\Plugin\Content\MokoOG`
|
||||
|
||||
### Database Schema
|
||||
|
||||
Single table `#__mokoog_tags`:
|
||||
- `content_type` + `content_id` = unique key identifying any content item
|
||||
- `og_title`, `og_description`, `og_image`, `og_type` = custom OG overrides
|
||||
- `published` flag for enabling/disabling per-item
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||
- **Minification**: handled at build time (CI)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- PHP 8.1+ minimum
|
||||
- Joomla 4/5 DI container pattern: `services/provider.php` → Extension class
|
||||
- Legacy stub `.php` file required for plugin loader but empty
|
||||
- `SubscriberInterface` for event subscription (not `on*` method naming)
|
||||
- `bind() → check() → store()` for Table operations (not `save()`)
|
||||
- Language file placement: site (no `folder`) vs admin (`folder="administrator"`)
|
||||
- SPDX license headers on all PHP files
|
||||
@@ -0,0 +1,28 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information without explicit permission
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at hello@mokoconsulting.tech. All complaints will be reviewed and investigated.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Contributing to MokoJoomOpenGraph
|
||||
|
||||
Thank you for your interest in contributing to MokoJoomOpenGraph.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository on Gitea
|
||||
2. Create a feature branch from `dev` (`feature/your-feature`)
|
||||
3. Make your changes following the coding standards below
|
||||
4. Submit a pull request targeting `dev`
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
- `main` — stable releases only
|
||||
- `dev` — active development
|
||||
- `feature/*` — new features (target `dev`)
|
||||
- `fix/*` — bug fixes (target `dev`)
|
||||
- `hotfix/*` — urgent fixes (target `dev` or `main`)
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- PHP 8.1+ required
|
||||
- Follow Joomla coding standards
|
||||
- SPDX license headers on all PHP files
|
||||
- Use `SubscriberInterface` for event subscription
|
||||
- Use `bind() -> check() -> store()` for Table operations
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Report bugs and feature requests via [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/issues).
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under GPL-3.0-or-later.
|
||||
@@ -0,0 +1,318 @@
|
||||
# MokoSuiteOpenGraph — Code Assessment Issues
|
||||
|
||||
Generated: 2026-06-06
|
||||
Updated: 2026-06-21
|
||||
Reviewed: Full codebase (all PHP, SQL, XML, JS, CSS, templates)
|
||||
|
||||
---
|
||||
|
||||
## Status Legend
|
||||
|
||||
- FIXED — Verified resolved in codebase
|
||||
- OPEN — Still present, needs work
|
||||
- WONTFIX — Intentional or acceptable as-is
|
||||
|
||||
---
|
||||
|
||||
## Bugs
|
||||
|
||||
### BUG-01: Batch generation offset pagination skips articles — FIXED
|
||||
|
||||
**Severity:** High
|
||||
**File:** `source/packages/com_mokoog/src/Controller/BatchController.php:89`
|
||||
|
||||
The `process()` method now correctly uses `$db->setQuery($query, 0, $limit)` with a comment explaining that processed articles are automatically excluded by the LEFT JOIN filter.
|
||||
|
||||
---
|
||||
|
||||
### BUG-02: License key session flag set before check completes — FIXED
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:543`
|
||||
|
||||
Session flag is now set after the DB query succeeds, inside the try block but after query setup. If the query throws, the catch block runs without the flag being set.
|
||||
|
||||
---
|
||||
|
||||
### BUG-03: Hardcoded og:image dimensions are often wrong — FIXED
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:129-134`
|
||||
|
||||
Now uses `$this->getImageDimensions($image)` which calls `getimagesize()` to detect actual dimensions. Dimension meta tags only emitted when dimensions are successfully detected.
|
||||
|
||||
---
|
||||
|
||||
### BUG-04: `strlen()` vs `mb_strlen()` inconsistency in truncation — FIXED
|
||||
|
||||
**Severity:** Low
|
||||
**Files:** MokoOG.php, BatchController.php, HikaShopAdapter.php, K2Adapter.php
|
||||
|
||||
All instances now consistently use `mb_strlen()` for length checks with `mb_substr()` for truncation.
|
||||
|
||||
---
|
||||
|
||||
### BUG-05: `ImageGenerator::wrapText()` can produce broken output — FIXED
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php:156`
|
||||
|
||||
Now checks `mb_strlen($lines[2]) > 3` before truncating. Short lines get `'...'` appended instead.
|
||||
|
||||
---
|
||||
|
||||
## Potential Issues
|
||||
|
||||
### ISSUE-01: ContentType adapters exist but are never wired up — OPEN
|
||||
|
||||
**Severity:** High (wasted code)
|
||||
**Files:**
|
||||
- `source/packages/com_mokoog/src/ContentType/ContentTypeInterface.php`
|
||||
- `source/packages/com_mokoog/src/ContentType/HikaShopAdapter.php`
|
||||
- `source/packages/com_mokoog/src/ContentType/K2Adapter.php`
|
||||
- `source/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php`
|
||||
|
||||
The system plugin (`MokoOG.php`) still never references or loads these adapters. The `findImage()` and `loadOgData()` methods only handle `com_content`. Third-party content types get no auto-generated OG tags.
|
||||
|
||||
**Action:** Wire adapters into the system plugin's `onBeforeCompileHead` flow, or remove them if not planned for v1.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-02: `applySeoTags()` accesses internal `$doc->_links` property — OPEN
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:257-259`
|
||||
|
||||
Still directly accessing `$doc->_links` (protected/internal property). Fragile across Joomla versions.
|
||||
|
||||
**Fix:** Use `$doc->getHeadData()` to read links and `$doc->addHeadLink()` with proper clearing logic.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-03: No input sanitization on OG values before output — OPEN
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php`
|
||||
|
||||
No `htmlspecialchars()` or `InputFilter` found in the content plugin's save path. While Joomla's `setMetaData()` escapes on output, defense-in-depth recommends sanitizing on input.
|
||||
|
||||
**Fix:** Apply `htmlspecialchars()` or Joomla's `InputFilter` when saving OG data.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-04: `loadOgDataByType()` and `loadOgDataByMenu()` ignore language — OPEN
|
||||
|
||||
**Severity:** Medium
|
||||
**Files:**
|
||||
- `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:324-337` (`loadOgDataByType`)
|
||||
- `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:346-359` (`loadOgDataByMenu`)
|
||||
|
||||
These methods still have no language filter. On multilingual sites, category fallback or menu OG data could come from any language. The unique key is now `(content_type, content_id, language)` but these queries don't filter by language, so `loadObject()` returns an arbitrary match.
|
||||
|
||||
**Fix:** Add the same language filter pattern used in `loadOgData()`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-05: VirtueMart adapter interpolates language into table name — OPEN (low risk)
|
||||
|
||||
**Severity:** Low (defense-in-depth)
|
||||
**File:** `source/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php:34,47`
|
||||
|
||||
Language tag is interpolated into the table name. While `quoteName()` wraps the result, the language tag itself is not validated against an allowlist.
|
||||
|
||||
**Fix:** Validate tag format with a regex before interpolation.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-06: No admin list controller for publish/delete operations — OPEN
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/com_mokoog/src/Controller/`
|
||||
|
||||
No `TagsController extends AdminController` exists. The admin list view toolbar buttons for delete/publish/unpublish will produce task routing errors.
|
||||
|
||||
**Fix:** Add a `TagsController extends AdminController` with proper CSRF and ACL checks.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-07: CSV import/export does not handle `language` column — OPEN
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/com_mokoog/src/Controller/ImportExportController.php`
|
||||
|
||||
No reference to `language` found in the controller. Export omits the column, import creates records with default `*` language. Multilingual sites cannot bulk import/export language-specific OG data.
|
||||
|
||||
**Fix:** Add `language` as a column in export, and parse it on import with a fallback to `*`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-08: No ACL check in content plugin form injection — WONTFIX
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php:49`
|
||||
|
||||
Any user who can edit an article can modify OG tags. This is acceptable behavior for most sites — if you can edit the article, you should be able to control its social sharing appearance.
|
||||
|
||||
---
|
||||
|
||||
## New Issues (Found 2026-06-21)
|
||||
|
||||
### ISSUE-09: ImageGenerator uses @ error suppression on GD functions
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
|
||||
|
||||
All GD library calls use the `@` suppression operator, making debugging difficult. If the GD extension is missing or a font file is not found, failures are completely silent.
|
||||
|
||||
**Fix:** Replace `@` suppression with proper error checking and logging via `Log::add()`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-10: No TTF font file bundled or documented
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
|
||||
|
||||
The image generator requires a TTF font file for text overlay, but no font is included in the package and no fallback or documentation exists for configuring the font path.
|
||||
|
||||
**Fix:** Bundle a permissively-licensed font (e.g., Open Sans, Noto Sans) or document the required configuration.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-11: ImageGenerator cache grows unbounded
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
|
||||
|
||||
Generated images in `images/mokoog/generated/` are never cleaned up. On sites with many articles, this directory grows indefinitely.
|
||||
|
||||
**Fix:** Add a cleanup CLI command or admin button (see FEAT-07), or implement LRU/TTL-based cache eviction.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-12: JSON-LD missing common schema types
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php`
|
||||
|
||||
Only 4 schema types are implemented (Article, WebPage, BreadcrumbList, Organization). Missing: NewsArticle, BlogPosting, Product, VideoObject, Event — some of which correspond to existing `og_type` dropdown values.
|
||||
|
||||
**Fix:** Add at least NewsArticle and BlogPosting as Article subtypes.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-13: No API input validation beyond field whitelisting
|
||||
|
||||
**Severity:** Low
|
||||
**Files:**
|
||||
- `source/packages/com_mokoog/api/src/Controller/TagsController.php`
|
||||
- `source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php`
|
||||
|
||||
The REST API exposes full CRUD but has no validation for field content (e.g., max lengths, valid URLs for og_image/canonical_url, valid og_type values).
|
||||
|
||||
**Fix:** Add validation rules matching the form XML constraints.
|
||||
|
||||
---
|
||||
|
||||
## Feature Expansion Opportunities
|
||||
|
||||
### FEAT-01: Wire up ContentType adapter system — NOT IMPLEMENTED
|
||||
|
||||
Connect the existing `ContentTypeInterface` adapters to the system plugin so HikaShop products, K2 items, and VirtueMart products automatically get OG tags. Blocked by ISSUE-01.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-02: Admin edit view for individual OG tag records — NOT IMPLEMENTED
|
||||
|
||||
A `TagModel` and `tag.xml` form exist but there's no edit template (`tmpl/tag/`) or `TagController`. Users can only manage OG tags through article/menu editors.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-03: Publish/unpublish toggle in admin list — NOT IMPLEMENTED
|
||||
|
||||
Blocked by ISSUE-06 (no TagsController). The list view shows published status as text but has no clickable toggle.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-04: Actual image dimension detection for og:image meta — FIXED
|
||||
|
||||
Implemented via `getImageDimensions()` method using `getimagesize()`. See BUG-03.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-05: Duplicate OG tag detection — NOT IMPLEMENTED
|
||||
|
||||
No detection for conflicting OG meta tags from other extensions.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-06: Support og:video and og:audio URLs — NOT IMPLEMENTED
|
||||
|
||||
No `og_video` or `og_audio` columns, form fields, or rendering logic found anywhere in the codebase.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-07: Generated image cache cleanup — NOT IMPLEMENTED
|
||||
|
||||
No CLI command or admin purge button. See ISSUE-11.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-08: Sitemap integration — NOT IMPLEMENTED
|
||||
|
||||
No sitemap generation or integration exists.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-09: Social share preview in admin list — NOT IMPLEMENTED
|
||||
|
||||
No thumbnails or inline validation in the admin list view. Live preview only exists in the article/menu editor (via plg_content_mokoog).
|
||||
|
||||
---
|
||||
|
||||
### FEAT-10: Bulk OG tag editing — NOT IMPLEMENTED
|
||||
|
||||
No batch edit modal for selecting multiple items and changing common fields.
|
||||
|
||||
---
|
||||
|
||||
## Security Fixes (from CHANGELOG [Unreleased])
|
||||
|
||||
All 4 claimed security fixes have been **verified as implemented**:
|
||||
|
||||
| Fix | Status | Evidence |
|
||||
|-----|--------|----------|
|
||||
| JSON-LD XSS (#34) | IMPLEMENTED | `</` escaping in `JsonLdBuilder::toScriptTag()` |
|
||||
| ACL on Batch/ImportExport (#37) | IMPLEMENTED | `authorise()` checks on all controller methods |
|
||||
| CSV import validation (#35) | IMPLEMENTED | File type, MIME, size (2MB), content_type regex |
|
||||
| Multilingual data corruption (#41) | IMPLEMENTED | Language-aware load/save in content plugin |
|
||||
|
||||
Additional security review found **no vulnerabilities** for: SQL injection, CSRF, file upload, path traversal, code injection, or XSS in output.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Total | Fixed | Open | Won't Fix |
|
||||
|----------|-------|-------|------|-----------|
|
||||
| Bugs | 5 | 5 | 0 | 0 |
|
||||
| Issues | 13 | 0 | 12 | 1 |
|
||||
| Features | 10 | 1 | 9 | 0 |
|
||||
| Security | 4 | 4 | 0 | 0 |
|
||||
|
||||
### Priority for v1.0.0 Release
|
||||
|
||||
**Must fix:**
|
||||
- ISSUE-06: TagsController for admin list operations (publish/delete broken)
|
||||
- ISSUE-04: Language filter on loadOgDataByType/loadOgDataByMenu (data integrity on multilingual sites)
|
||||
|
||||
**Should fix:**
|
||||
- ISSUE-02: Replace `$doc->_links` access (Joomla version fragility)
|
||||
- ISSUE-03: Input sanitization on save (defense-in-depth)
|
||||
- ISSUE-09: GD error suppression (debuggability)
|
||||
- ISSUE-10: Bundle or document TTF font requirement
|
||||
|
||||
**Nice to have for v1.0.0:**
|
||||
- FEAT-02: Admin edit view
|
||||
- FEAT-03: Publish/unpublish toggle
|
||||
- ISSUE-07: Language column in CSV import/export
|
||||
@@ -2,7 +2,7 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# MokoOpenGraph — Open Graph & social sharing meta tag management
|
||||
# MokoJoomOpenGraph — Open Graph & social sharing meta tag management
|
||||
|
||||
# ==============================================================================
|
||||
# CONFIGURATION - Customize these for your extension
|
||||
|
||||
@@ -1,40 +1,69 @@
|
||||
# MokoOpenGraph
|
||||
# MokoSuiteOpenGraph
|
||||
|
||||
<!-- VERSION: 01.00.00 -->
|
||||
<!-- VERSION: 01.03.00 -->
|
||||
|
||||
Open Graph, Twitter Card, and social sharing meta tag management for Joomla 4/5/6.
|
||||
|
||||
## Overview
|
||||
|
||||
MokoOpenGraph gives you full control over how your Joomla content appears when shared on Facebook, Twitter/X, LinkedIn, WhatsApp, and other social platforms. Set custom titles, descriptions, and images per article and menu item — or let the extension auto-generate them from your existing content.
|
||||
MokoSuiteOpenGraph gives you full control over how your Joomla content appears when shared on Facebook, Twitter/X, LinkedIn, Discord, WhatsApp, Telegram, and other social platforms. Set custom titles, descriptions, and images per article, menu item, and category — or let the extension auto-generate them from your existing content.
|
||||
|
||||
## Features
|
||||
|
||||
- **Open Graph tags** — `og:title`, `og:description`, `og:image`, `og:url`, `og:type`, `og:site_name`
|
||||
### Social Meta Tags
|
||||
- **Open Graph tags** — `og:title`, `og:description`, `og:image`, `og:url`, `og:type`, `og:site_name`, `og:locale`
|
||||
- **Twitter/X Cards** — Summary and Summary with Large Image card types
|
||||
- **Per-article control** — Custom OG fields in the article editor
|
||||
- **LinkedIn** — `article:published_time`, `article:modified_time`, `article:author`
|
||||
- **Discord** — Custom embed color via `theme-color` meta tag
|
||||
- **Telegram** — `telegram:channel` for link previews
|
||||
- **Facebook** — `fb:app_id` support, `og:image:width`/`og:image:height` for instant previews
|
||||
|
||||
### Content Management
|
||||
- **Per-article control** — Custom OG fields tab in the article editor
|
||||
- **Per-menu-item control** — Custom OG fields in the menu item editor
|
||||
- **Auto-generation** — Automatically builds tags from article content, title, and images
|
||||
- **Default fallback image** — Site-wide default when no article image exists
|
||||
- **Admin tag manager** — View and manage all OG records from a central dashboard
|
||||
- **Facebook App ID** — Optional `fb:app_id` meta tag support
|
||||
- **Joomla 4/5/6** — Modern DI container architecture, Joomla coding standards
|
||||
- **Per-category control** — Category-level OG tag overrides
|
||||
- **Multilingual support** — Per-language OG data with language-aware fallback
|
||||
- **Auto-generation** — Builds tags from article content, title, and images automatically
|
||||
- **Site-wide defaults** — Default OG title, description, and image for all pages
|
||||
|
||||
### SEO
|
||||
- **SEO title override** — Custom `<title>` tag per page
|
||||
- **Meta description** — Per-page meta description control
|
||||
- **Robots directive** — Per-page noindex/nofollow settings
|
||||
- **Canonical URL** — Custom canonical URL overrides
|
||||
- **JSON-LD structured data** — Article, Product, WebPage, BreadcrumbList, Organization schemas
|
||||
|
||||
### Admin Tools
|
||||
- **Tag manager dashboard** — View and manage all OG records centrally
|
||||
- **Batch generation** — Auto-generate OG tags for all existing articles
|
||||
- **CSV import/export** — Bulk manage OG data via CSV files
|
||||
- **SEO health badges** — Visual indicators for missing descriptions, long titles, noindex
|
||||
- **Debug links** — Quick links to Facebook Debugger, LinkedIn Inspector, Google Rich Results
|
||||
- **Live preview** — Real-time Facebook and Twitter/X card preview in the editor
|
||||
|
||||
### Developer Features
|
||||
- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`)
|
||||
- **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta
|
||||
- **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags
|
||||
- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases)
|
||||
1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/releases)
|
||||
2. In Joomla Administrator → Extensions → Install → Upload Package File
|
||||
3. The system plugin is enabled automatically on install
|
||||
3. All plugins are enabled automatically on install
|
||||
|
||||
## Configuration
|
||||
|
||||
Navigate to **Extensions → Plugins → System - MokoOpenGraph** to configure:
|
||||
Navigate to **Extensions → Plugins → System - MokoSuiteOpenGraph** to configure:
|
||||
- Site name override
|
||||
- Default OG title and description (site-wide fallback)
|
||||
- Default fallback image
|
||||
- Twitter Card type and @username
|
||||
- Facebook App ID
|
||||
- Auto-generation behavior
|
||||
- Description length limit
|
||||
- Discord embed color
|
||||
- Telegram channel
|
||||
- Auto-generation, image resize, JSON-LD, and description length settings
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
+3
-1
@@ -17,8 +17,10 @@
|
||||
"require-dev": {
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"joomla/coding-standards": "^4.0"
|
||||
"joomla/coding-standards": "^3.0"
|
||||
},
|
||||
"minimum-stability": "alpha",
|
||||
"prefer-stable": true,
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,7 +1,7 @@
|
||||
; MokoOpenGraph - Package System Language File
|
||||
; MokoJoomOpenGraph - Package System Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PKG_MOKOOG="MokoOpenGraph"
|
||||
PKG_MOKOOG="MokoJoomOpenGraph"
|
||||
PKG_MOKOOG_DESCRIPTION="Complete Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Control how every page appears when shared on Facebook, Twitter/X, LinkedIn, WhatsApp, and more."
|
||||
PKG_MOKOOG_PHP_VERSION_ERROR="MokoOpenGraph requires PHP %s or later."
|
||||
PKG_MOKOOG_PHP_VERSION_ERROR="MokoJoomOpenGraph requires PHP %s or later."
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,7 +1,7 @@
|
||||
; MokoOpenGraph - Package System Language File
|
||||
; MokoJoomOpenGraph - Package System Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PKG_MOKOOG="MokoOpenGraph"
|
||||
PKG_MOKOOG="MokoJoomOpenGraph"
|
||||
PKG_MOKOOG_DESCRIPTION="Complete Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Control how every page appears when shared on Facebook, Twitter/X, LinkedIn, WhatsApp, and more."
|
||||
PKG_MOKOOG_PHP_VERSION_ERROR="MokoOpenGraph requires PHP %s or later."
|
||||
PKG_MOKOOG_PHP_VERSION_ERROR="MokoJoomOpenGraph requires PHP %s or later."
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog.api
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\ApiController;
|
||||
|
||||
class TagsController extends ApiController
|
||||
{
|
||||
/**
|
||||
* The content type for JSON:API output.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $contentType = 'tags';
|
||||
|
||||
/**
|
||||
* The default view for the API.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $default_view = 'tags';
|
||||
|
||||
/**
|
||||
* Lookup an OG tag by content_type and content_id.
|
||||
*
|
||||
* GET /api/index.php/v1/mokoog/lookup/:content_type/:content_id
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function lookup(): static
|
||||
{
|
||||
$contentType = $this->input->getString('content_type', '');
|
||||
$contentId = $this->input->getInt('content_id', 0);
|
||||
|
||||
if (empty($contentType) || $contentId <= 0) {
|
||||
throw new \RuntimeException('content_type and content_id are required', 400);
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
||||
->where($db->quoteName('content_id') . ' = ' . $contentId);
|
||||
|
||||
$db->setQuery($query);
|
||||
$id = $db->loadResult();
|
||||
|
||||
if (!$id) {
|
||||
throw new \RuntimeException('OG tag not found for ' . $contentType . ':' . $contentId, 404);
|
||||
}
|
||||
|
||||
$this->input->set('id', $id);
|
||||
|
||||
return $this->displayItem();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog.api
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Api\View\Tags;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
|
||||
|
||||
class JsonapiView extends BaseApiView
|
||||
{
|
||||
/**
|
||||
* The fields to render in the API response.
|
||||
*
|
||||
* Whitelist of fields from #__mokoog_tags that are safe to expose.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fieldsToRenderItem = [
|
||||
'id',
|
||||
'content_type',
|
||||
'content_id',
|
||||
'og_title',
|
||||
'og_description',
|
||||
'og_image',
|
||||
'og_type',
|
||||
'seo_title',
|
||||
'meta_description',
|
||||
'robots',
|
||||
'canonical_url',
|
||||
'language',
|
||||
'published',
|
||||
'created',
|
||||
'modified',
|
||||
];
|
||||
|
||||
/**
|
||||
* The fields to render in list responses.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fieldsToRenderList = [
|
||||
'id',
|
||||
'content_type',
|
||||
'content_id',
|
||||
'og_title',
|
||||
'og_description',
|
||||
'og_image',
|
||||
'og_type',
|
||||
'seo_title',
|
||||
'meta_description',
|
||||
'robots',
|
||||
'canonical_url',
|
||||
'language',
|
||||
'published',
|
||||
'created',
|
||||
'modified',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -70,4 +70,37 @@
|
||||
<option value="0">JUNPUBLISHED</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="seo" label="SEO Meta Tags">
|
||||
<field
|
||||
name="seo_title"
|
||||
type="text"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
|
||||
filter="string"
|
||||
maxlength="70"
|
||||
/>
|
||||
<field
|
||||
name="meta_description"
|
||||
type="textarea"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
|
||||
filter="string"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
/>
|
||||
<field
|
||||
name="robots"
|
||||
type="text"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="canonical_url"
|
||||
type="url"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
|
||||
filter="url"
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,59 @@
|
||||
; MokoJoomOpenGraph - Component Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOOG="MokoJoomOpenGraph"
|
||||
COM_MOKOOG_TAGS_TITLE="MokoJoomOpenGraph - Tag Manager"
|
||||
COM_MOKOOG_SUBMENU_TAGS="Tags"
|
||||
COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items."
|
||||
COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags"
|
||||
COM_MOKOOG_AUTO_GENERATED="auto-generated"
|
||||
|
||||
COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type"
|
||||
COM_MOKOOG_HEADING_CONTENT_ID="Content ID"
|
||||
COM_MOKOOG_HEADING_OG_TITLE="OG Title"
|
||||
COM_MOKOOG_HEADING_IMAGE="Image"
|
||||
COM_MOKOOG_HEADING_SEO="SEO"
|
||||
COM_MOKOOG_HEADING_DEBUG="Debug"
|
||||
COM_MOKOOG_HEADING_MODIFIED="Modified"
|
||||
|
||||
COM_MOKOOG_SEO_OK="OK"
|
||||
COM_MOKOOG_SEO_MISSING_DESC="No meta description"
|
||||
COM_MOKOOG_SEO_TITLE_LONG="SEO title too long"
|
||||
COM_MOKOOG_SEO_NOINDEX="noindex"
|
||||
|
||||
COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type"
|
||||
COM_MOKOOG_FIELD_CONTENT_ID="Content ID"
|
||||
COM_MOKOOG_FIELD_OG_TITLE="OG Title"
|
||||
COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing."
|
||||
COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
|
||||
COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing."
|
||||
COM_MOKOOG_FIELD_OG_IMAGE="OG Image"
|
||||
COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing."
|
||||
COM_MOKOOG_FIELD_OG_TYPE="OG Type"
|
||||
COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type."
|
||||
|
||||
COM_MOKOOG_FILTER_SEARCH="Search OG titles"
|
||||
COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
|
||||
COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -"
|
||||
COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending"
|
||||
COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending"
|
||||
COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending"
|
||||
COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending"
|
||||
|
||||
COM_MOKOOG_TOOLBAR_BATCH_GENERATE="Batch Generate"
|
||||
COM_MOKOOG_BATCH_TITLE="Batch OG Tag Generation"
|
||||
COM_MOKOOG_BATCH_COUNTING="Counting articles without OG tags..."
|
||||
COM_MOKOOG_BATCH_NONE="All articles already have OG tags."
|
||||
COM_MOKOOG_BATCH_FOUND="articles found without OG tags."
|
||||
COM_MOKOOG_BATCH_PROCESSED="processed"
|
||||
COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!"
|
||||
COM_MOKOOG_BATCH_ERROR="Error:"
|
||||
|
||||
COM_MOKOOG_TOOLBAR_EXPORT="Export CSV"
|
||||
COM_MOKOOG_TOOLBAR_IMPORT="Import CSV"
|
||||
COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded."
|
||||
COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file."
|
||||
COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s."
|
||||
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
|
||||
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
; MokoOpenGraph - Component System Language File
|
||||
; MokoJoomOpenGraph - Component System Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOOG="MokoOpenGraph"
|
||||
COM_MOKOOG="MokoJoomOpenGraph"
|
||||
COM_MOKOOG_DESCRIPTION="Manage Open Graph and social sharing tags for all your content. View, edit, and batch-process OG metadata."
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,59 @@
|
||||
; MokoJoomOpenGraph - Component Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOOG="MokoJoomOpenGraph"
|
||||
COM_MOKOOG_TAGS_TITLE="MokoJoomOpenGraph - Tag Manager"
|
||||
COM_MOKOOG_SUBMENU_TAGS="Tags"
|
||||
COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items."
|
||||
COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags"
|
||||
COM_MOKOOG_AUTO_GENERATED="auto-generated"
|
||||
|
||||
COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type"
|
||||
COM_MOKOOG_HEADING_CONTENT_ID="Content ID"
|
||||
COM_MOKOOG_HEADING_OG_TITLE="OG Title"
|
||||
COM_MOKOOG_HEADING_IMAGE="Image"
|
||||
COM_MOKOOG_HEADING_SEO="SEO"
|
||||
COM_MOKOOG_HEADING_DEBUG="Debug"
|
||||
COM_MOKOOG_HEADING_MODIFIED="Modified"
|
||||
|
||||
COM_MOKOOG_SEO_OK="OK"
|
||||
COM_MOKOOG_SEO_MISSING_DESC="No meta description"
|
||||
COM_MOKOOG_SEO_TITLE_LONG="SEO title too long"
|
||||
COM_MOKOOG_SEO_NOINDEX="noindex"
|
||||
|
||||
COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type"
|
||||
COM_MOKOOG_FIELD_CONTENT_ID="Content ID"
|
||||
COM_MOKOOG_FIELD_OG_TITLE="OG Title"
|
||||
COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing."
|
||||
COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
|
||||
COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing."
|
||||
COM_MOKOOG_FIELD_OG_IMAGE="OG Image"
|
||||
COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing."
|
||||
COM_MOKOOG_FIELD_OG_TYPE="OG Type"
|
||||
COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type."
|
||||
|
||||
COM_MOKOOG_FILTER_SEARCH="Search OG titles"
|
||||
COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
|
||||
COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -"
|
||||
COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending"
|
||||
COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending"
|
||||
COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending"
|
||||
COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending"
|
||||
|
||||
COM_MOKOOG_TOOLBAR_BATCH_GENERATE="Batch Generate"
|
||||
COM_MOKOOG_BATCH_TITLE="Batch OG Tag Generation"
|
||||
COM_MOKOOG_BATCH_COUNTING="Counting articles without OG tags..."
|
||||
COM_MOKOOG_BATCH_NONE="All articles already have OG tags."
|
||||
COM_MOKOOG_BATCH_FOUND="articles found without OG tags."
|
||||
COM_MOKOOG_BATCH_PROCESSED="processed"
|
||||
COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!"
|
||||
COM_MOKOOG_BATCH_ERROR="Error:"
|
||||
|
||||
COM_MOKOOG_TOOLBAR_EXPORT="Export CSV"
|
||||
COM_MOKOOG_TOOLBAR_IMPORT="Import CSV"
|
||||
COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded."
|
||||
COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file."
|
||||
COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s."
|
||||
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
|
||||
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
; MokoOpenGraph - Component System Language File
|
||||
; MokoJoomOpenGraph - Component System Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOOG="MokoOpenGraph"
|
||||
COM_MOKOOG="MokoJoomOpenGraph"
|
||||
COM_MOKOOG_DESCRIPTION="Manage Open Graph and social sharing tags for all your content. View, edit, and batch-process OG metadata."
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokoog</name>
|
||||
<version>01.00.00</version>
|
||||
<version>01.03.00</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -42,6 +42,7 @@
|
||||
<filename>provider.php</filename>
|
||||
</files>
|
||||
<files folder="src">
|
||||
<folder>ContentType</folder>
|
||||
<folder>Controller</folder>
|
||||
<folder>Extension</folder>
|
||||
<folder>Model</folder>
|
||||
@@ -68,4 +69,10 @@
|
||||
<menu link="option=com_mokoog&view=tags">COM_MOKOOG_SUBMENU_TAGS</menu>
|
||||
</submenu>
|
||||
</administration>
|
||||
|
||||
<api>
|
||||
<files folder="api">
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
</api>
|
||||
</extension>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -23,7 +23,7 @@ class Com_MokoOGInstallerScript
|
||||
*/
|
||||
public function install(InstallerAdapter $parent): void
|
||||
{
|
||||
echo '<p>MokoOpenGraph component installed successfully.</p>';
|
||||
echo '<p>MokoJoomOpenGraph component installed successfully.</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +35,6 @@ class Com_MokoOGInstallerScript
|
||||
*/
|
||||
public function update(InstallerAdapter $parent): void
|
||||
{
|
||||
echo '<p>MokoOpenGraph component updated successfully.</p>';
|
||||
echo '<p>MokoJoomOpenGraph component updated successfully.</p>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
--
|
||||
-- MokoOpenGraph - Database Schema
|
||||
-- MokoJoomOpenGraph - Database Schema
|
||||
-- Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
-- License: GPL-3.0-or-later
|
||||
--
|
||||
@@ -12,10 +12,15 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` (
|
||||
`og_description` TEXT NOT NULL,
|
||||
`og_image` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`og_type` VARCHAR(50) NOT NULL DEFAULT 'article',
|
||||
`seo_title` VARCHAR(70) NOT NULL DEFAULT '',
|
||||
`meta_description` VARCHAR(200) NOT NULL DEFAULT '',
|
||||
`robots` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`canonical_url` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`language` CHAR(7) NOT NULL DEFAULT '*',
|
||||
`published` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_content` (`content_type`, `content_id`),
|
||||
UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`),
|
||||
KEY `idx_published` (`published`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
--
|
||||
-- MokoOpenGraph - Uninstall
|
||||
-- MokoJoomOpenGraph - Uninstall
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `#__mokoog_tags`;
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,9 @@
|
||||
--
|
||||
-- MokoJoomOpenGraph 01.01.00 — Add SEO meta management columns
|
||||
--
|
||||
|
||||
ALTER TABLE `#__mokoog_tags`
|
||||
ADD COLUMN `seo_title` VARCHAR(70) NOT NULL DEFAULT '' AFTER `og_type`,
|
||||
ADD COLUMN `meta_description` VARCHAR(200) NOT NULL DEFAULT '' AFTER `seo_title`,
|
||||
ADD COLUMN `robots` VARCHAR(100) NOT NULL DEFAULT '' AFTER `meta_description`,
|
||||
ADD COLUMN `canonical_url` VARCHAR(512) NOT NULL DEFAULT '' AFTER `robots`;
|
||||
@@ -0,0 +1,10 @@
|
||||
--
|
||||
-- MokoJoomOpenGraph 01.02.00 — Add multilingual OG tag support
|
||||
--
|
||||
|
||||
ALTER TABLE `#__mokoog_tags`
|
||||
ADD COLUMN `language` CHAR(7) NOT NULL DEFAULT '*' AFTER `canonical_url`;
|
||||
|
||||
ALTER TABLE `#__mokoog_tags`
|
||||
DROP INDEX `idx_content`,
|
||||
ADD UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`);
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Response\JsonResponse;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
class BatchController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Count the total articles eligible for batch generation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function count(): void
|
||||
{
|
||||
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__content', 'c'))
|
||||
->leftJoin(
|
||||
$db->quoteName('#__mokoog_tags', 't')
|
||||
. ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content')
|
||||
. ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id')
|
||||
)
|
||||
->where($db->quoteName('c.state') . ' = 1')
|
||||
->where($db->quoteName('t.id') . ' IS NULL');
|
||||
|
||||
$db->setQuery($query);
|
||||
$total = (int) $db->loadResult();
|
||||
|
||||
echo new JsonResponse(['total' => $total]);
|
||||
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a chunk of articles for batch OG generation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function process(): void
|
||||
{
|
||||
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$limit = min($app->getInput()->getInt('limit', 50), 200);
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName([
|
||||
'c.id', 'c.title', 'c.metadesc', 'c.introtext', 'c.fulltext', 'c.images',
|
||||
]))
|
||||
->from($db->quoteName('#__content', 'c'))
|
||||
->leftJoin(
|
||||
$db->quoteName('#__mokoog_tags', 't')
|
||||
. ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content')
|
||||
. ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id')
|
||||
)
|
||||
->where($db->quoteName('c.state') . ' = 1')
|
||||
->where($db->quoteName('t.id') . ' IS NULL')
|
||||
->order($db->quoteName('c.id') . ' ASC');
|
||||
|
||||
// Always offset=0: processed articles now have #__mokoog_tags rows
|
||||
// and are excluded by the LEFT JOIN ... IS NULL filter automatically.
|
||||
$db->setQuery($query, 0, $limit);
|
||||
$articles = $db->loadObjectList();
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
foreach ($articles as $article) {
|
||||
$ogTitle = $article->title;
|
||||
$ogDescription = $this->extractDescription($article);
|
||||
$ogImage = $this->extractImage($article);
|
||||
|
||||
$record = (object) [
|
||||
'content_type' => 'com_content',
|
||||
'content_id' => (int) $article->id,
|
||||
'og_title' => $ogTitle,
|
||||
'og_description' => $ogDescription,
|
||||
'og_image' => $ogImage,
|
||||
'og_type' => 'article',
|
||||
'seo_title' => '',
|
||||
'meta_description' => $article->metadesc ?: '',
|
||||
'robots' => '',
|
||||
'canonical_url' => '',
|
||||
'language' => '*',
|
||||
'published' => 1,
|
||||
'created' => $now,
|
||||
'modified' => $now,
|
||||
];
|
||||
|
||||
try {
|
||||
$db->insertObject('#__mokoog_tags', $record);
|
||||
$created++;
|
||||
} catch (\RuntimeException $e) {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
echo new JsonResponse([
|
||||
'created' => $created,
|
||||
]);
|
||||
|
||||
$app->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a description from article content.
|
||||
*
|
||||
* @param object $article Article record
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function extractDescription(object $article): string
|
||||
{
|
||||
// Prefer meta description if set
|
||||
if (!empty($article->metadesc)) {
|
||||
return $article->metadesc;
|
||||
}
|
||||
|
||||
// Fall back to intro text
|
||||
$text = $article->introtext ?: $article->fulltext;
|
||||
$text = strip_tags($text);
|
||||
$text = trim(preg_replace('/\s+/', ' ', $text));
|
||||
|
||||
if (mb_strlen($text) > 160) {
|
||||
$text = mb_substr($text, 0, 157) . '...';
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the best image from article data.
|
||||
*
|
||||
* @param object $article Article record
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function extractImage(object $article): string
|
||||
{
|
||||
if (!empty($article->images)) {
|
||||
$images = json_decode($article->images, true);
|
||||
|
||||
if (!empty($images['image_fulltext'])) {
|
||||
return $images['image_fulltext'];
|
||||
}
|
||||
|
||||
if (!empty($images['image_intro'])) {
|
||||
return $images['image_intro'];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
class ImportExportController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Maximum upload file size in bytes (2 MB).
|
||||
*/
|
||||
private const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Allowed content_type patterns for import.
|
||||
*/
|
||||
private const CONTENT_TYPE_PATTERN = '/^[a-z][a-z0-9_.]*$/';
|
||||
|
||||
/**
|
||||
* Export all OG tags as CSV.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function export(): void
|
||||
{
|
||||
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Join with #__content to get article titles for reference
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t.content_type'),
|
||||
$db->quoteName('t.content_id'),
|
||||
'COALESCE(' . $db->quoteName('c.title') . ', ' . $db->quote('') . ') AS ' . $db->quoteName('article_title'),
|
||||
$db->quoteName('t.og_title'),
|
||||
$db->quoteName('t.og_description'),
|
||||
$db->quoteName('t.og_image'),
|
||||
$db->quoteName('t.og_type'),
|
||||
$db->quoteName('t.seo_title'),
|
||||
$db->quoteName('t.meta_description'),
|
||||
$db->quoteName('t.robots'),
|
||||
$db->quoteName('t.canonical_url'),
|
||||
$db->quoteName('t.language'),
|
||||
])
|
||||
->from($db->quoteName('#__mokoog_tags', 't'))
|
||||
->leftJoin(
|
||||
$db->quoteName('#__content', 'c')
|
||||
. ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content')
|
||||
. ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id')
|
||||
)
|
||||
->order($db->quoteName('t.content_type') . ', ' . $db->quoteName('t.content_id'));
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
// Send CSV headers
|
||||
$app->setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
$app->setHeader('Content-Disposition', 'attachment; filename="mokoog_tags_export.csv"');
|
||||
$app->sendHeaders();
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Header row
|
||||
fputcsv($output, [
|
||||
'content_type', 'content_id', 'article_title',
|
||||
'og_title', 'og_description', 'og_image', 'og_type',
|
||||
'seo_title', 'meta_description', 'robots', 'canonical_url',
|
||||
'language',
|
||||
]);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($output, $row);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Import OG tags from uploaded CSV.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function import(): void
|
||||
{
|
||||
Session::checkToken() || jexit(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$identity = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!$identity->authorise('core.create', 'com_mokoog') || !$identity->authorise('core.edit', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$input = $app->getInput();
|
||||
$files = $input->files->get('jform', [], 'array');
|
||||
|
||||
if (empty($files['csv_file']['tmp_name'])) {
|
||||
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_NO_FILE'), 'error');
|
||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$csvFile = $files['csv_file'];
|
||||
|
||||
// Validate file extension
|
||||
$ext = strtolower(pathinfo($csvFile['name'] ?? '', PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext !== 'csv') {
|
||||
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error');
|
||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
$allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel'];
|
||||
|
||||
if (!empty($csvFile['type']) && !\in_array($csvFile['type'], $allowedMimes, true)) {
|
||||
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error');
|
||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (($csvFile['size'] ?? 0) > self::MAX_FILE_SIZE) {
|
||||
$app->enqueueMessage(Text::sprintf('COM_MOKOOG_IMPORT_FILE_TOO_LARGE', '2 MB'), 'error');
|
||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tmpFile = $csvFile['tmp_name'];
|
||||
$handle = fopen($tmpFile, 'r');
|
||||
|
||||
if (!$handle) {
|
||||
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_READ_ERROR'), 'error');
|
||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$header = fgetcsv($handle);
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
if (\count($row) < 7) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$contentType = trim($row[0]);
|
||||
$contentId = (int) $row[1];
|
||||
// $row[2] = article_title (informational, skip)
|
||||
$ogTitle = trim($row[3] ?? '');
|
||||
$ogDescription = trim($row[4] ?? '');
|
||||
$ogImage = trim($row[5] ?? '');
|
||||
$ogType = trim($row[6] ?? 'article');
|
||||
$seoTitle = trim($row[7] ?? '');
|
||||
$metaDesc = trim($row[8] ?? '');
|
||||
$robots = trim($row[9] ?? '');
|
||||
$canonicalUrl = trim($row[10] ?? '');
|
||||
$language = trim($row[11] ?? '*');
|
||||
|
||||
// Validate language tag format (e.g., 'en-GB', '*')
|
||||
if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) {
|
||||
$language = '*';
|
||||
}
|
||||
|
||||
if (empty($contentType) || $contentId <= 0) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate content_type against allowed pattern
|
||||
if (!preg_match(self::CONTENT_TYPE_PATTERN, $contentType)) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for existing record (unique key includes language)
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
||||
->where($db->quoteName('content_id') . ' = ' . $contentId)
|
||||
->where($db->quoteName('language') . ' = ' . $db->quote($language));
|
||||
|
||||
$db->setQuery($query);
|
||||
$existingId = $db->loadResult();
|
||||
|
||||
$record = (object) [
|
||||
'content_type' => $contentType,
|
||||
'content_id' => $contentId,
|
||||
'og_title' => $ogTitle,
|
||||
'og_description' => $ogDescription,
|
||||
'og_image' => $ogImage,
|
||||
'og_type' => $ogType,
|
||||
'seo_title' => $seoTitle,
|
||||
'meta_description' => $metaDesc,
|
||||
'robots' => $robots,
|
||||
'canonical_url' => $canonicalUrl,
|
||||
'language' => $language,
|
||||
'published' => 1,
|
||||
'modified' => $now,
|
||||
];
|
||||
|
||||
if ($existingId) {
|
||||
$record->id = $existingId;
|
||||
$db->updateObject('#__mokoog_tags', $record, 'id');
|
||||
$updated++;
|
||||
} else {
|
||||
$record->created = $now;
|
||||
$db->insertObject('#__mokoog_tags', $record);
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
$app->enqueueMessage(
|
||||
Text::sprintf('COM_MOKOOG_IMPORT_RESULT', $created, $updated, $skipped),
|
||||
'success'
|
||||
);
|
||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\AdminController;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class TagsController extends AdminController
|
||||
{
|
||||
/**
|
||||
* Proxy for getModel.
|
||||
*
|
||||
* @param string $name Model name
|
||||
* @param string $prefix Model prefix
|
||||
* @param array $config Configuration array
|
||||
*
|
||||
* @return BaseDatabaseModel
|
||||
*/
|
||||
public function getModel($name = 'Tag', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
||||
{
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\AdminModel;
|
||||
|
||||
class TagModel extends AdminModel
|
||||
{
|
||||
/**
|
||||
* Get the form for the item.
|
||||
*
|
||||
* @param array $data Form data
|
||||
* @param bool $loadData Load data from state
|
||||
*
|
||||
* @return \Joomla\CMS\Form\Form|false
|
||||
*/
|
||||
public function getForm($data = [], $loadData = true)
|
||||
{
|
||||
$form = $this->loadForm(
|
||||
'com_mokoog.tag',
|
||||
'tag',
|
||||
['control' => 'jform', 'load_data' => $loadData]
|
||||
);
|
||||
|
||||
return $form ?: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the form data.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
protected function loadFormData(): object
|
||||
{
|
||||
$data = Factory::getApplication()->getUserState('com_mokoog.edit.tag.data', []);
|
||||
|
||||
if (empty($data)) {
|
||||
$data = $this->getItem();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the table class name.
|
||||
*
|
||||
* @param string $name Table name
|
||||
* @param string $prefix Table prefix
|
||||
* @param array $options Table options
|
||||
*
|
||||
* @return \Joomla\CMS\Table\Table
|
||||
*/
|
||||
public function getTable($name = 'Tag', $prefix = 'Administrator', $options = [])
|
||||
{
|
||||
return parent::getTable($name, $prefix, $options);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
class TagTable extends Table
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DatabaseDriver $db Database driver instance
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokoog_tags', 'id', $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform checks before store.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private const VALID_OG_TYPES = [
|
||||
'article', 'website', 'product', 'profile', 'book', 'music.song',
|
||||
'music.album', 'video.movie', 'video.episode', 'video.other',
|
||||
];
|
||||
|
||||
private const VALID_ROBOTS = [
|
||||
'index', 'noindex', 'follow', 'nofollow', 'none', 'noarchive',
|
||||
'nosnippet', 'noimageindex', 'max-snippet', 'max-image-preview',
|
||||
];
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->content_type)) {
|
||||
$this->setError('Content type is required.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-z][a-z0-9_.]*$/', $this->content_type)) {
|
||||
$this->setError('Content type contains invalid characters.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->content_id)) {
|
||||
$this->setError('Content ID is required.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate og_type against known values
|
||||
if (!empty($this->og_type) && !\in_array($this->og_type, self::VALID_OG_TYPES, true)) {
|
||||
$this->og_type = 'article';
|
||||
}
|
||||
|
||||
// Truncate fields to schema max lengths
|
||||
if (mb_strlen($this->og_title ?? '') > 255) {
|
||||
$this->og_title = mb_substr($this->og_title, 0, 255);
|
||||
}
|
||||
|
||||
if (mb_strlen($this->seo_title ?? '') > 70) {
|
||||
$this->seo_title = mb_substr($this->seo_title, 0, 70);
|
||||
}
|
||||
|
||||
if (mb_strlen($this->meta_description ?? '') > 200) {
|
||||
$this->meta_description = mb_substr($this->meta_description, 0, 200);
|
||||
}
|
||||
|
||||
// Validate canonical_url format if non-empty
|
||||
if (!empty($this->canonical_url) && !filter_var($this->canonical_url, FILTER_VALIDATE_URL)) {
|
||||
$this->canonical_url = '';
|
||||
}
|
||||
|
||||
// Validate robots directives
|
||||
if (!empty($this->robots)) {
|
||||
$parts = array_map('trim', explode(',', strtolower($this->robots)));
|
||||
$valid = array_filter($parts, function ($part) {
|
||||
// Allow directives with values like "max-snippet:-1"
|
||||
$directive = explode(':', $part)[0];
|
||||
|
||||
return \in_array($directive, self::VALID_ROBOTS, true);
|
||||
});
|
||||
$this->robots = $valid ? implode(', ', $valid) : '';
|
||||
}
|
||||
|
||||
// Default language to '*' if not set
|
||||
if (empty($this->language)) {
|
||||
$this->language = '*';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
+22
-4
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -39,6 +39,20 @@ class HtmlView extends BaseHtmlView
|
||||
*/
|
||||
protected $state;
|
||||
|
||||
/**
|
||||
* The filter form.
|
||||
*
|
||||
* @var \Joomla\CMS\Form\Form|null
|
||||
*/
|
||||
public $filterForm;
|
||||
|
||||
/**
|
||||
* The active filters.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $activeFilters = [];
|
||||
|
||||
/**
|
||||
* Display the view.
|
||||
*
|
||||
@@ -48,9 +62,11 @@ class HtmlView extends BaseHtmlView
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
$this->state = $this->get('State');
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
$this->state = $this->get('State');
|
||||
$this->filterForm = $this->get('FilterForm');
|
||||
$this->activeFilters = $this->get('ActiveFilters');
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
@@ -65,6 +81,8 @@ class HtmlView extends BaseHtmlView
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOOG_TAGS_TITLE'), 'bookmark');
|
||||
ToolbarHelper::custom('batch.generate', 'refresh', '', 'COM_MOKOOG_TOOLBAR_BATCH_GENERATE', false);
|
||||
ToolbarHelper::custom('importexport.export', 'download', '', 'COM_MOKOOG_TOOLBAR_EXPORT', false);
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete');
|
||||
ToolbarHelper::preferences('com_mokoog');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Layout\LayoutHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/** @var \Joomla\Component\MokoOG\Administrator\View\Tags\HtmlView $this */
|
||||
|
||||
$token = Session::getFormToken();
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div id="j-main-container" class="j-main-container">
|
||||
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOOG_NO_TAGS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<table class="table" id="tagList">
|
||||
<caption class="visually-hidden">
|
||||
<?php echo Text::_('COM_MOKOOG_TABLE_CAPTION'); ?>
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="w-1 text-center">
|
||||
<?php echo HTMLHelper::_('grid.checkall'); ?>
|
||||
</td>
|
||||
<th scope="col">
|
||||
<?php echo Text::_('COM_MOKOOG_HEADING_CONTENT_TYPE'); ?>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<?php echo Text::_('COM_MOKOOG_HEADING_CONTENT_ID'); ?>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<?php echo Text::_('COM_MOKOOG_HEADING_OG_TITLE'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo Text::_('COM_MOKOOG_HEADING_SEO'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo Text::_('JSTATUS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo Text::_('COM_MOKOOG_HEADING_DEBUG'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo Text::_('COM_MOKOOG_HEADING_MODIFIED'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo Text::_('JGRID_HEADING_ID'); ?>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $i => $item) : ?>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<?php echo HTMLHelper::_('grid.id', $i, $item->id); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $this->escape($item->content_type); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo (int) $item->content_id; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $this->escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($item->og_image) : ?>
|
||||
<span class="icon-image" aria-hidden="true" title="<?php echo $this->escape($item->og_image); ?>"></span>
|
||||
<?php else : ?>
|
||||
<span class="icon-minus-circle text-muted" aria-hidden="true"></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
$seoIssues = [];
|
||||
|
||||
if (empty($item->meta_description)) {
|
||||
$seoIssues[] = Text::_('COM_MOKOOG_SEO_MISSING_DESC');
|
||||
}
|
||||
|
||||
if (!empty($item->seo_title) && \strlen($item->seo_title) > 60) {
|
||||
$seoIssues[] = Text::_('COM_MOKOOG_SEO_TITLE_LONG');
|
||||
}
|
||||
|
||||
if (!empty($item->robots) && str_contains($item->robots, 'noindex')) {
|
||||
$seoIssues[] = Text::_('COM_MOKOOG_SEO_NOINDEX');
|
||||
}
|
||||
|
||||
if (empty($seoIssues)) : ?>
|
||||
<span class="badge bg-success"><?php echo Text::_('COM_MOKOOG_SEO_OK'); ?></span>
|
||||
<?php else : ?>
|
||||
<?php foreach ($seoIssues as $issue) : ?>
|
||||
<span class="badge bg-warning text-dark"><?php echo $issue; ?></span>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $item->published ? Text::_('JPUBLISHED') : Text::_('JUNPUBLISHED'); ?>
|
||||
</td>
|
||||
<td class="mokoog-debug-links">
|
||||
<?php
|
||||
// Build frontend URL for this content item
|
||||
if ($item->content_type === 'com_content') {
|
||||
$debugUrl = Uri::root() . 'index.php?option=com_content&view=article&id=' . (int) $item->content_id;
|
||||
} elseif ($item->content_type === 'menu') {
|
||||
$debugUrl = Uri::root() . 'index.php?Itemid=' . (int) $item->content_id;
|
||||
} elseif ($item->content_type === 'com_content.category') {
|
||||
$debugUrl = Uri::root() . 'index.php?option=com_content&view=category&id=' . (int) $item->content_id;
|
||||
} else {
|
||||
$debugUrl = Uri::root();
|
||||
}
|
||||
?>
|
||||
<a href="https://developers.facebook.com/tools/debug/?q=<?php echo urlencode($debugUrl); ?>" target="_blank" rel="noopener" title="Facebook Debugger" class="btn btn-sm btn-outline-primary">FB</a>
|
||||
<a href="https://www.linkedin.com/post-inspector/inspect/<?php echo urlencode($debugUrl); ?>" target="_blank" rel="noopener" title="LinkedIn Inspector" class="btn btn-sm btn-outline-info">LI</a>
|
||||
<a href="https://search.google.com/test/rich-results?url=<?php echo urlencode($debugUrl); ?>" target="_blank" rel="noopener" title="Google Rich Results" class="btn btn-sm btn-outline-success">G</a>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('date', $item->modified, Text::_('DATE_FORMAT_LC4')); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo (int) $item->id; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php echo $this->pagination->getListFooter(); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<input type="hidden" name="task" value="">
|
||||
<input type="hidden" name="boxchecked" value="0">
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Batch Generation Progress -->
|
||||
<div id="mokoog-batch-panel" style="display:none;" class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h4><?php echo Text::_('COM_MOKOOG_BATCH_TITLE'); ?></h4>
|
||||
<div class="progress mb-2">
|
||||
<div id="mokoog-batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%">0%</div>
|
||||
</div>
|
||||
<p id="mokoog-batch-status"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Intercept the batch.generate toolbar button
|
||||
var origSubmitbutton = Joomla.submitbutton;
|
||||
Joomla.submitbutton = function(task) {
|
||||
if (task === 'batch.generate') {
|
||||
mokoogBatchGenerate();
|
||||
return;
|
||||
}
|
||||
if (origSubmitbutton) {
|
||||
origSubmitbutton(task);
|
||||
}
|
||||
};
|
||||
|
||||
function mokoogBatchGenerate() {
|
||||
var panel = document.getElementById('mokoog-batch-panel');
|
||||
var bar = document.getElementById('mokoog-batch-bar');
|
||||
var status = document.getElementById('mokoog-batch-status');
|
||||
var token = '<?php echo $token; ?>';
|
||||
var chunkSize = 50;
|
||||
|
||||
panel.style.display = 'block';
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COUNTING', true); ?>';
|
||||
|
||||
// Step 1: Count eligible articles
|
||||
fetch('index.php?option=com_mokoog&task=batch.count&format=json&' + token + '=1')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(resp) {
|
||||
var total = resp.data.total;
|
||||
if (total === 0) {
|
||||
bar.style.width = '100%';
|
||||
bar.textContent = '100%';
|
||||
bar.classList.remove('progress-bar-animated');
|
||||
bar.classList.add('bg-success');
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_NONE', true); ?>';
|
||||
return;
|
||||
}
|
||||
status.textContent = total + ' <?php echo Text::_('COM_MOKOOG_BATCH_FOUND', true); ?>';
|
||||
processChunk(0, total, chunkSize, token, bar, status);
|
||||
})
|
||||
.catch(function(err) {
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function processChunk(processed, total, chunkSize, token, bar, status) {
|
||||
// Always offset=0: processed items are excluded by the IS NULL filter
|
||||
fetch('index.php?option=com_mokoog&task=batch.process&format=json&limit=' + chunkSize + '&' + token + '=1')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(resp) {
|
||||
processed += resp.data.created;
|
||||
var pct = Math.min(100, Math.round((processed / total) * 100));
|
||||
bar.style.width = pct + '%';
|
||||
bar.textContent = pct + '%';
|
||||
status.textContent = processed + ' / ' + total + ' <?php echo Text::_('COM_MOKOOG_BATCH_PROCESSED', true); ?>';
|
||||
|
||||
if (resp.data.created > 0 && processed < total) {
|
||||
processChunk(processed, total, chunkSize, token, bar, status);
|
||||
} else {
|
||||
bar.classList.remove('progress-bar-animated');
|
||||
bar.classList.add('bg-success');
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COMPLETE', true); ?> ' + processed + ' articles.';
|
||||
setTimeout(function() { location.reload(); }, 2000);
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
+44
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @package MokoOpenGraph
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage plg_content_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
@@ -50,5 +50,48 @@
|
||||
<option value="video.other">Video</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_seo" label="PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC">
|
||||
<field
|
||||
name="seo_title"
|
||||
type="text"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
|
||||
filter="string"
|
||||
maxlength="70"
|
||||
/>
|
||||
<field
|
||||
name="meta_description"
|
||||
type="textarea"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
|
||||
filter="string"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
/>
|
||||
<field
|
||||
name="robots"
|
||||
type="list"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
|
||||
default=""
|
||||
multiple="true"
|
||||
>
|
||||
<option value="">PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT</option>
|
||||
<option value="noindex">noindex</option>
|
||||
<option value="nofollow">nofollow</option>
|
||||
<option value="nosnippet">nosnippet</option>
|
||||
<option value="noarchive">noarchive</option>
|
||||
<option value="noimageindex">noimageindex</option>
|
||||
</field>
|
||||
<field
|
||||
name="canonical_url"
|
||||
type="url"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
|
||||
filter="url"
|
||||
validate="url"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</form>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,28 @@
|
||||
; MokoJoomOpenGraph - Content Plugin Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_LABEL="Open Graph / Social Sharing"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_DESC="Configure how this content appears when shared on social media."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TITLE="OG Title"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing. Leave blank to use the article title."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing. Leave blank to auto-generate from content."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title"
|
||||
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom <title> tag. 50-60 characters recommended. Leave blank to use the default page title."
|
||||
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
|
||||
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default."
|
||||
PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive"
|
||||
PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)."
|
||||
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
; MokoOpenGraph - Content Plugin System Language File
|
||||
; MokoJoomOpenGraph - Content Plugin System Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_CONTENT_MOKOOG="Content - MokoOpenGraph"
|
||||
PLG_CONTENT_MOKOOG="Content - MokoJoomOpenGraph"
|
||||
PLG_CONTENT_MOKOOG_DESCRIPTION="Adds Open Graph fields to article and menu item edit forms for per-page social sharing control."
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,28 @@
|
||||
; MokoJoomOpenGraph - Content Plugin Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_LABEL="Open Graph / Social Sharing"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_DESC="Configure how this content appears when shared on social media."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TITLE="OG Title"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing. Leave blank to use the article title."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing. Leave blank to auto-generate from content."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title"
|
||||
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom <title> tag. 50-60 characters recommended. Leave blank to use the default page title."
|
||||
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
|
||||
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default."
|
||||
PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive"
|
||||
PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)."
|
||||
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
; MokoOpenGraph - Content Plugin System Language File
|
||||
; MokoJoomOpenGraph - Content Plugin System Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_CONTENT_MOKOOG="Content - MokoOpenGraph"
|
||||
PLG_CONTENT_MOKOOG="Content - MokoJoomOpenGraph"
|
||||
PLG_CONTENT_MOKOOG_DESCRIPTION="Adds Open Graph fields to article and menu item edit forms for per-page social sharing control."
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user