dev #23

Merged
jmiller merged 117 commits from dev into main 2026-05-14 20:04:40 +00:00
63 changed files with 5900 additions and 21613 deletions
-54
View File
@@ -1,54 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
MokoStandards Repository Manifest
Auto-generated by MokoStandards bulk sync.
Manual edits to <governance> and <last-synced> may be overwritten.
See: docs/standards/mokostandards-file-spec.md
-->
<mokostandards xmlns="https://standards.mokoconsulting.tech/mokostandards/1.0" schema-version="1.0">
<identity>
<name>MokoOnyx</name>
<org>MokoConsulting</org>
<description>MokoOnyx - Joomla site template (successor to MokoCassiopeia)</description>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
<platform>joomla</platform>
<standards-version>04.07.00</standards-version>
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards</standards-source>
<last-synced>2026-05-02T23:06:05+00:00</last-synced>
</governance>
<build>
<language>CSS</language>
<runtime>php:&gt;=8.1</runtime>
<package-type>joomla-extension</package-type>
<entry-point>src/templateDetails.xml</entry-point>
</build>
<scripts>
<script name="lint" phase="lint">
<command>make lint</command>
<description>Lint via make</description>
<runner>make</runner>
</script>
<script name="validate" phase="validate">
<command>make validate</command>
<description>Validate via make</description>
<runner>make</runner>
</script>
<script name="clean" phase="build">
<command>make clean</command>
<description>Clean via make</description>
<runner>make</runner>
</script>
<script name="build" phase="build">
<command>make build</command>
<description>Build via make</description>
<runner>make</runner>
</script>
<script name="release" phase="release">
<command>make release</command>
<description>Release via make</description>
<runner>make</runner>
</script>
</scripts>
</mokostandards>
+6
View File
@@ -117,6 +117,8 @@ site/
*.map
*.css.map
*.js.map
*.min.css
*.min.js
*.tsbuildinfo
# ============================================================
@@ -211,6 +213,10 @@ hypothesis/
src/media/css/theme/*.custom.css
src/media/css/theme/*.custom.min.css
templates/*.custom.css
# User override files (site-specific, not version controlled)
src/media/css/user.css
src/media/js/user.js
update.xml
.moko-standards
profile.ps1
-9
View File
@@ -1,9 +0,0 @@
{
"mcpServers": {
"joomla-api": {
"type": "stdio",
"command": "node",
"args": ["A:/joomla-api-mcp/dist/index.js"]
}
}
}
+110
View File
@@ -0,0 +1,110 @@
---
name: Architecture Decision Record (ADR)
about: Propose or document an architectural decision
title: '[ADR] '
labels: 'architecture, decision'
assignees: ''
---
## ADR Number
ADR-XXXX
## Status
- [ ] Proposed
- [ ] Accepted
- [ ] Deprecated
- [ ] Superseded by ADR-XXXX
## Context
Describe the issue or problem that motivates this decision.
## Decision
State the architecture decision and provide rationale.
## Consequences
### Positive
- List positive consequences
### Negative
- List negative consequences or trade-offs
### Neutral
- List neutral aspects
## Alternatives Considered
### Alternative 1
- Description
- Pros
- Cons
- Why not chosen
### Alternative 2
- Description
- Pros
- Cons
- Why not chosen
## Implementation Plan
1. Step 1
2. Step 2
3. Step 3
## Stakeholders
- **Decision Makers**: @user1, @user2
- **Consulted**: @user3, @user4
- **Informed**: team-name
## Technical Details
### Architecture Diagram
```
[Add diagram or link]
```
### Dependencies
- Dependency 1
- Dependency 2
### Impact Analysis
- **Performance**: [Impact description]
- **Security**: [Impact description]
- **Scalability**: [Impact description]
- **Maintainability**: [Impact description]
## Testing Strategy
- [ ] Unit tests
- [ ] Integration tests
- [ ] Performance tests
- [ ] Security tests
## Documentation
- [ ] Architecture documentation updated
- [ ] API documentation updated
- [ ] Developer guide updated
- [ ] Runbook created
## Migration Path
Describe how to migrate from current state to new architecture.
## Rollback Plan
Describe how to rollback if issues occur.
## Timeline
- **Proposal Date**:
- **Decision Date**:
- **Implementation Start**:
- **Expected Completion**:
## References
- Related ADRs:
- External resources:
- RFCs:
## Review Checklist
- [ ] Aligns with enterprise architecture principles
- [ ] Security implications reviewed
- [ ] Performance implications reviewed
- [ ] Cost implications reviewed
- [ ] Compliance requirements met
- [ ] Team consensus achieved
+48
View File
@@ -0,0 +1,48 @@
---
name: Bug Report
about: Report a bug or issue with the project
title: '[BUG] '
labels: 'bug'
assignees: ''
---
## Bug Description
A clear and concise description of what the bug is.
## Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
## Expected Behavior
A clear and concise description of what you expected to happen.
## Actual Behavior
A clear and concise description of what actually happened.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Environment
- **Project**: [e.g., MokoDoliTools, moko-cassiopeia]
- **Version**: [e.g., 1.2.3]
- **Platform**: [e.g., Dolibarr 18.0, Joomla 5.0]
- **PHP Version**: [e.g., 8.1]
- **Database**: [e.g., MySQL 8.0, PostgreSQL 14]
- **Browser** (if applicable): [e.g., Chrome 120, Firefox 121]
- **OS**: [e.g., Ubuntu 22.04, Windows 11]
## Additional Context
Add any other context about the problem here.
## Possible Solution
If you have suggestions on how to fix the issue, please describe them here.
## Checklist
- [ ] I have searched for similar issues before creating this one
- [ ] I have provided all the requested information
- [ ] I have tested this on the latest stable version
- [ ] I have checked the documentation and couldn't find a solution
+18
View File
@@ -0,0 +1,18 @@
---
blank_issues_enabled: true
contact_links:
- name: 💼 Enterprise Support
url: https://mokoconsulting.tech/enterprise
about: Enterprise-level support and consultation services
- name: 💬 Ask a Question
url: https://mokoconsulting.tech/
about: Get help or ask questions through our website
- name: 📚 MokoStandards Documentation
url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
about: View our coding standards and best practices
- name: 🔒 Report a Security Vulnerability
url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new
about: Report security vulnerabilities privately (for critical issues)
- name: 💡 Community Discussions
url: https://github.com/orgs/mokoconsulting-tech/discussions
about: Join community discussions and Q&A
@@ -0,0 +1,52 @@
---
name: Documentation Issue
about: Report an issue with documentation
title: '[DOCS] '
labels: 'documentation'
assignees: ''
---
## Documentation Issue
**Location**:
<!-- Specify the file, page, or section with the issue -->
## Issue Type
<!-- Mark the relevant option with an "x" -->
- [ ] Typo or grammar error
- [ ] Outdated information
- [ ] Missing documentation
- [ ] Unclear explanation
- [ ] Broken links
- [ ] Missing examples
- [ ] Other (specify below)
## Description
<!-- Clearly describe the documentation issue -->
## Current Content
<!-- Quote or describe the current documentation (if applicable) -->
```
Current text here
```
## Suggested Improvement
<!-- Provide your suggestion for how to improve the documentation -->
```
Suggested text here
```
## Additional Context
<!-- Add any other context, screenshots, or references -->
## Standards Alignment
- [ ] Follows MokoStandards documentation guidelines
- [ ] Uses en_US/en_GB localization
- [ ] Includes proper SPDX headers where applicable
## Checklist
- [ ] I have searched for similar documentation issues
- [ ] I have provided a clear description
- [ ] I have suggested an improvement (if applicable)
@@ -0,0 +1,51 @@
---
name: Feature Request
about: Suggest a new feature or enhancement
title: '[FEATURE] '
labels: 'enhancement'
assignees: ''
---
## Feature Description
A clear and concise description of the feature you'd like to see.
## Problem or Use Case
Describe the problem this feature would solve or the use case it addresses.
Ex. I'm always frustrated when [...]
## Proposed Solution
A clear and concise description of what you want to happen.
## Alternative Solutions
A clear and concise description of any alternative solutions or features you've considered.
## Benefits
Describe how this feature would benefit users:
- Who would use this feature?
- What problems does it solve?
- What value does it add?
## Implementation Details (Optional)
If you have ideas about how this could be implemented, share them here:
- Technical approach
- Files/components that might need changes
- Any concerns or challenges you foresee
## Additional Context
Add any other context, mockups, or screenshots about the feature request here.
## Relevant Standards
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
- [ ] Accessibility (WCAG 2.1 AA)
- [ ] Localization (en_US/en_GB)
- [ ] Security best practices
- [ ] Code quality standards
- [ ] Other: [specify]
## Checklist
- [ ] I have searched for similar feature requests before creating this one
- [ ] I have clearly described the use case and benefits
- [ ] I have considered alternative solutions
- [ ] This feature aligns with the project's goals and scope
+87
View File
@@ -0,0 +1,87 @@
---
name: Joomla Extension Issue
about: Report an issue with a Joomla extension
title: '[JOOMLA] '
labels: 'joomla'
assignees: ''
---
## Issue Type
- [ ] Component Issue
- [ ] Module Issue
- [ ] Plugin Issue
- [ ] Template Issue
## Extension Details
- **Extension Name**: [e.g., moko-cassiopeia]
- **Extension Version**: [e.g., 1.2.3]
- **Extension Type**: [Component / Module / Plugin / Template]
## Joomla Environment
- **Joomla Version**: [e.g., 4.4.0, 5.0.0]
- **PHP Version**: [e.g., 8.1.0]
- **Database**: [MySQL / PostgreSQL / MariaDB]
- **Database Version**: [e.g., 8.0]
- **Server**: [Apache / Nginx / IIS]
- **Hosting**: [Shared / VPS / Dedicated / Cloud]
## Issue Description
Provide a clear and detailed description of the issue.
## Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. Configure '...'
4. See error
## Expected Behavior
What you expected to happen.
## Actual Behavior
What actually happened.
## Error Messages
```
# Paste any error messages from Joomla error logs
# Location: administrator/logs/error.php
```
## Browser Console Errors
```javascript
// Paste any JavaScript console errors (F12 in browser)
```
## Screenshots
Add screenshots to help explain the issue.
## Configuration
```ini
# Paste extension configuration (sanitize sensitive data)
```
## Installed Extensions
List other installed extensions that might conflict:
- Extension 1 (version)
- Extension 2 (version)
## Template Overrides
- [ ] Using template overrides
- [ ] Custom CSS
- [ ] Custom JavaScript
## Additional Context
- **Multilingual Site**: [Yes / No]
- **Cache Enabled**: [Yes / No]
- **Debug Mode**: [Yes / No]
- **SEF URLs**: [Yes / No]
## Checklist
- [ ] I have cleared Joomla cache
- [ ] I have disabled other extensions to test for conflicts
- [ ] I have checked Joomla error logs
- [ ] I have tested with a default Joomla template
- [ ] I have checked browser console for JavaScript errors
- [ ] I have searched for similar issues
- [ ] I am using a supported Joomla version
+82
View File
@@ -0,0 +1,82 @@
---
name: Question
about: Ask a question about usage, features, or best practices
title: '[QUESTION] '
labels: ['question']
assignees: ['jmiller']
---
## Question
**Your question:**
## Context
**What are you trying to accomplish?**
**What have you already tried?**
**Category**:
- [ ] Script usage
- [ ] Configuration
- [ ] Workflow setup
- [ ] Documentation interpretation
- [ ] Best practices
- [ ] Integration
- [ ] Other: __________
## Environment (if relevant)
**Your setup**:
- Operating System:
- Version:
## What You've Researched
**Documentation reviewed**:
- [ ] README.md
- [ ] Project documentation
- [ ] Other (specify): __________
**Similar issues/questions found**:
- #
- #
## Expected Outcome
**What result are you hoping for?**
## Code/Configuration Samples
**Relevant code or configuration** (if applicable):
```bash
# Your code here
```
## Additional Context
**Any other relevant information:**
**Screenshots** (if helpful):
## Urgency
- [ ] Urgent (blocking work)
- [ ] Normal (can work on other things meanwhile)
- [ ] Low priority (just curious)
## Checklist
- [ ] I have searched existing issues and discussions
- [ ] I have reviewed relevant documentation
- [ ] I have provided sufficient context
- [ ] I have included code/configuration samples if relevant
- [ ] This is a genuine question (not a bug report or feature request)
+126
View File
@@ -0,0 +1,126 @@
---
name: Request for Comments (RFC)
about: Propose a significant change for community discussion
title: '[RFC] '
labels: 'rfc, discussion'
assignees: ''
---
## RFC Summary
One-paragraph summary of the proposal.
## Motivation
Why are we doing this? What use cases does it support? What is the expected outcome?
## Detailed Design
### Overview
Provide a detailed explanation of the proposed change.
### API Changes (if applicable)
```php
// Before
function oldApi($param1) { }
// After
function newApi($param1, $param2) { }
```
### User Experience Changes
Describe how users will interact with this change.
### Implementation Approach
High-level implementation strategy.
## Drawbacks
Why should we *not* do this?
## Alternatives
What other designs have been considered? What is the impact of not doing this?
### Alternative 1
- Description
- Trade-offs
### Alternative 2
- Description
- Trade-offs
## Adoption Strategy
How will existing users adopt this? Is this a breaking change?
### Migration Guide
```bash
# Steps to migrate
```
### Deprecation Timeline
- **Announcement**:
- **Deprecation**:
- **Removal**:
## Unresolved Questions
- Question 1
- Question 2
## Future Possibilities
What future work does this enable?
## Impact Assessment
### Performance
Expected performance impact.
### Security
Security considerations and implications.
### Compatibility
- **Backward Compatible**: [Yes / No]
- **Breaking Changes**: [List]
### Maintenance
Long-term maintenance considerations.
## Community Input
### Stakeholders
- [ ] Core team
- [ ] Module developers
- [ ] End users
- [ ] Enterprise customers
### Feedback Period
**Duration**: [e.g., 2 weeks]
**Deadline**: [date]
## Implementation Timeline
### Phase 1: Design
- [ ] RFC discussion
- [ ] Design finalization
- [ ] Approval
### Phase 2: Implementation
- [ ] Core implementation
- [ ] Tests
- [ ] Documentation
### Phase 3: Release
- [ ] Beta release
- [ ] Feedback collection
- [ ] Stable release
## Success Metrics
How will we measure success?
- Metric 1
- Metric 2
## References
- Related RFCs:
- External documentation:
- Prior art:
## Open Questions for Community
1. Question 1?
2. Question 2?
---
**Note**: This RFC is open for community discussion. Please provide feedback in the comments below.
+51
View File
@@ -0,0 +1,51 @@
---
name: Security Vulnerability Report
about: Report a security vulnerability (use only for non-critical issues)
title: '[SECURITY] '
labels: 'security'
assignees: ''
---
## ⚠️ IMPORTANT: Private Disclosure Required
**For critical security vulnerabilities, DO NOT use this template.**
Follow the process in [SECURITY.md](../SECURITY.md) for responsible disclosure.
Use this template only for:
- Security improvements
- Non-critical security suggestions
- Security documentation updates
---
## Security Issue
**Severity**:
<!-- Low, Medium, or informational only -->
## Description
<!-- Describe the security concern or improvement suggestion -->
## Affected Components
<!-- List the affected files, features, or components -->
## Suggested Mitigation
<!-- Describe how this could be addressed -->
## Standards Reference
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
- [ ] SPDX license identifiers
- [ ] Secret management
- [ ] Dependency security
- [ ] Access control
- [ ] Other: [specify]
## Additional Context
<!-- Add any other context about the security concern -->
## Checklist
- [ ] This is NOT a critical vulnerability requiring private disclosure
- [ ] I have reviewed the SECURITY.md policy
- [ ] I have provided sufficient detail for evaluation
+24
View File
@@ -0,0 +1,24 @@
---
name: Version Bump
about: Request or track a version change
title: '[VERSION] '
labels: 'version, type: version'
assignees: 'jmiller'
---
## Version Change
**Current version**: <!-- e.g., 01.02.03 -->
**Requested version**: <!-- e.g., 01.03.00 -->
**Change type**: <!-- patch / minor / major -->
## Reason
<!-- Why is this version bump needed? -->
## Checklist
- [ ] README.md `VERSION:` field updated
- [ ] CHANGELOG.md entry added
- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: `<version>`)
- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow
@@ -151,13 +151,22 @@ jobs:
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
fi
# Promote [Unreleased] section in CHANGELOG.md to new version
if [ -f "CHANGELOG.md" ] && grep -qi "Unreleased" CHANGELOG.md; then
sed -i "s|## \[Unreleased\]|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md
sed -i "s|## Unreleased|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md
sed -i "2i ## [Unreleased]" CHANGELOG.md
sed -i "3i \\ " CHANGELOG.md
echo "CHANGELOG promoted to [${VERSION}]"
fi
# Commit and push
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} (minor) [skip ci]"
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
git push origin HEAD:main 2>&1
}
@@ -311,6 +320,7 @@ jobs:
# -- STEP 5: Write updates.xml (Joomla update server) ---------------------
- name: "Step 5: Write updates.xml"
id: updates
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
@@ -334,20 +344,44 @@ jobs:
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
# If EXT_NAME is a language key (e.g. PLG_SYSTEM_MOKOJGDPC), resolve from .ini
if echo "$EXT_NAME" | grep -qE '^[A-Z_]+$'; then
INI_NAME=$(find . -name "*.sys.ini" -path "*/en-GB/*" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
[ -z "$INI_NAME" ] && INI_NAME=$(find . -name "*.sys.ini" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
[ -n "$INI_NAME" ] && EXT_NAME="$INI_NAME"
fi
# Fallbacks
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
# Derive element if not in manifest:
# 1. Try XML filename (e.g. mokowaas.xml → mokowaas)
# 2. Fall back to repo name (lowercased)
# 1. plugin="xxx" attribute (plugins)
# 2. module="xxx" attribute (modules)
# 3. XML filename (components, packages)
# 4. Repo name fallback (templates, anything else)
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
fi
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(sed -n 's/.*module="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
fi
if [ -z "$EXT_ELEMENT" ]; then
FNAME=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
# If filename is generic (templateDetails, manifest), use repo name
case "$EXT_ELEMENT" in
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
case "$FNAME" in
templatedetails|manifest) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
*) EXT_ELEMENT="$FNAME" ;;
esac
fi
# Final fallback
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
# Save for Steps 7, 8, 8b
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "ext_name=${EXT_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_type=${EXT_TYPE}" >> "$GITHUB_OUTPUT"
echo "ext_folder=${EXT_FOLDER}" >> "$GITHUB_OUTPUT"
# Build client tag: plugins and frontend modules need <client>site</client>
CLIENT_TAG=""
@@ -374,7 +408,18 @@ jobs:
PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
fi
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${EXT_ELEMENT}-${VERSION}.zip"
# Build TYPE_PREFIX for download URL
TYPE_PREFIX=""
case "${EXT_TYPE}" in
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
module) TYPE_PREFIX="mod_" ;;
component) TYPE_PREFIX="com_" ;;
template) TYPE_PREFIX="tpl_" ;;
library) TYPE_PREFIX="lib_" ;;
package) TYPE_PREFIX="pkg_" ;;
esac
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable"
# -- Build update entry for a given stability tag
@@ -469,21 +514,32 @@ jobs:
MAJOR="${{ steps.version.outputs.major }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Auto-detect extension element for release naming
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
EXT_ELEMENT=""
if [ -n "$MANIFEST" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; esac
else
# Reuse metadata from Step 5 (single source of truth)
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
EXT_NAME="${{ steps.updates.outputs.ext_name }}"
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
# Fallbacks if Step 5 was skipped
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
[ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
RELEASE_NAME="${EXT_ELEMENT} ${VERSION} (stable)"
# Build release name: "Pretty Name VERSION (type_element-VERSION)"
TYPE_PREFIX=""
case "${EXT_TYPE}" in
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
module) TYPE_PREFIX="mod_" ;;
component) TYPE_PREFIX="com_" ;;
template) TYPE_PREFIX="tpl_" ;;
library) TYPE_PREFIX="lib_" ;;
package) TYPE_PREFIX="pkg_" ;;
esac
RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
# Delete existing release if present (overwrite, not append)
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
@@ -533,9 +589,28 @@ jobs:
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
[ -z "$MANIFEST" ] && exit 0
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
TAR_NAME="${EXT_ELEMENT}-${VERSION}.tar.gz"
# Reuse element from Step 5, with same fallback chain
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
# ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
TYPE_PREFIX=""
case "${EXT_TYPE}" in
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
module) TYPE_PREFIX="mod_" ;;
component) TYPE_PREFIX="com_" ;;
template) TYPE_PREFIX="tpl_" ;;
library) TYPE_PREFIX="lib_" ;;
package) TYPE_PREFIX="pkg_" ;;
esac
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
# -- Build install packages from src/ ----------------------------
SOURCE_DIR="src"
@@ -675,6 +750,73 @@ jobs:
echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
# -- STEP 8b: Update release description with changelog + SHA ----------------
- name: "Step 8b: Update release body with changelog and SHA"
if: steps.version.outputs.skip != 'true'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
# Build TYPE_PREFIX to match Step 8's ZIP naming
TYPE_PREFIX=""
case "${EXT_TYPE}" in
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
module) TYPE_PREFIX="mod_" ;;
component) TYPE_PREFIX="com_" ;;
template) TYPE_PREFIX="tpl_" ;;
library) TYPE_PREFIX="lib_" ;;
package) TYPE_PREFIX="pkg_" ;;
esac
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
# Get SHA from the built files
SHA256_ZIP=""
[ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
SHA256_TAR=""
[ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
# Extract latest changelog entry (strip the ## header to avoid duplicate)
CHANGELOG=""
if [ -f "CHANGELOG.md" ]; then
CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
[ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
fi
# Build release body (single header, no duplicate from changelog)
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
if [ -n "$CHANGELOG" ]; then
BODY="${BODY}${CHANGELOG}\n\n"
fi
BODY="${BODY}---\n\n### Checksums\n\n"
BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
[ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
[ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
# Get release ID and update body
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
python3 -c "
import json, urllib.request
body = '''$(printf '%b' "$BODY")'''
data = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=data,
headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
method='PATCH'
)
urllib.request.urlopen(req)
" 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
@@ -764,6 +906,26 @@ jobs:
done
echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY
# -- STEP 11: Reset dev branch from main ------------------------------------
- name: "Step 11: Delete and recreate dev branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.GA_TOKEN }}"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
@@ -375,3 +375,76 @@ jobs:
else
echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY
fi
static-analysis:
name: PHPStan Analysis
runs-on: ubuntu-latest
needs: lint-and-validate
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --optimize-autoloader
fi
- name: Install PHPStan
run: |
if ! command -v vendor/bin/phpstan &> /dev/null; then
composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \
composer global require phpstan/phpstan --no-interaction
fi
- name: Run PHPStan
run: |
echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY
PHPSTAN="vendor/bin/phpstan"
if [ ! -f "$PHPSTAN" ]; then
PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan
fi
# Determine source directory
SRC_DIR=""
for DIR in src/ htdocs/ lib/; do
if [ -d "$DIR" ]; then
SRC_DIR="$DIR"
break
fi
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
exit 0
fi
# Use repo phpstan.neon if present, otherwise use baseline config
ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table"
if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then
echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY
else
ARGS="$ARGS --level=3"
echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY
fi
$PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt
EXIT=${PIPESTATUS[0]}
if [ $EXIT -eq 0 ]; then
echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY
else
ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some")
echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit $EXIT
+111
View File
@@ -0,0 +1,111 @@
# When MokoOnyx CSS changes hit main:
# 1. Sync base CSS to Template-Client-WaaS (the single source for clients)
# 2. If new CSS variables were added, create issues on individual client repos
name: Sync CSS to Client Template
on:
push:
branches: [main]
paths:
- 'src/media/templates/site/mokoonyx/css/**'
- 'media/templates/site/mokoonyx/css/**'
permissions:
contents: read
jobs:
sync:
name: Sync to Template and Notify Clients
runs-on: ubuntu-latest
steps:
- name: Checkout MokoOnyx
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Sync CSS to Template-Client-WaaS
env:
GITEA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${{ github.server_url }}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
TEMPLATE="MokoConsulting/Template-Client-WaaS"
CSS_DIR="src/media/templates/site/mokoonyx/css"
[ ! -d "$CSS_DIR" ] && CSS_DIR="media/templates/site/mokoonyx/css"
# Sync base CSS files only (user.css and *.custom.css are client-owned)
find "$CSS_DIR" -name "*.css" -not -name "user.css" -not -name "*.custom.css" | while read -r file; do
rel_path="src/media/templates/site/mokoonyx/css/${file#${CSS_DIR}/}"
content_b64=$(base64 -w0 "$file")
sha=$(curl -sf -H "$AUTH" "${API}/repos/${TEMPLATE}/contents/${rel_path}" | jq -r '.sha // empty')
if [ -n "$sha" ]; then
curl -sf -X PUT -H "$AUTH" -H "Content-Type: application/json" \
"${API}/repos/${TEMPLATE}/contents/${rel_path}" \
-d "{\"content\": \"${content_b64}\", \"sha\": \"${sha}\", \"message\": \"chore: sync CSS from MokoOnyx\"}" \
-o /dev/null && echo "Updated: ${rel_path}"
else
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
"${API}/repos/${TEMPLATE}/contents/${rel_path}" \
-d "{\"content\": \"${content_b64}\", \"message\": \"chore: sync CSS from MokoOnyx\"}" \
-o /dev/null && echo "Created: ${rel_path}"
fi
done
- name: Extract all CSS variables from MokoOnyx base
id: vars
env:
GITEA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${{ github.server_url }}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
CSS_DIR="src/media/templates/site/mokoonyx/css"
[ ! -d "$CSS_DIR" ] && CSS_DIR="media/templates/site/mokoonyx/css"
# Get ALL variables defined in MokoOnyx base CSS (excluding custom files)
ALL_VARS=$(find "$CSS_DIR" -name "*.css" -not -name "*.custom.css" -not -name "user.css" -exec grep -ohE '\-\-[a-z][a-z0-9-]+' {} \; | sort -u)
echo "$ALL_VARS" > /tmp/all_vars.txt
echo "Total base variables: $(wc -l < /tmp/all_vars.txt)"
# Check each client repo for missing variables
CLIENTS=(
"ClarksvilleFurs/client-waas-clarksvillefurs"
"KiddieLand/client-waas-kiddieland"
"VexCreations/client-waas-vexcreations"
)
for repo in "${CLIENTS[@]}"; do
echo "=== Checking ${repo} ==="
MISSING=""
for theme in "dark" "light"; do
FILE_PATH="src/media/templates/site/mokoonyx/css/theme/${theme}.custom.css"
CLIENT_CSS=$(curl -sf -H "$AUTH" "${API}/repos/${repo}/contents/${FILE_PATH}" | jq -r '.content // empty' | base64 -d 2>/dev/null || echo "")
if [ -z "$CLIENT_CSS" ]; then
MISSING="$MISSING\nAll variables missing from ${theme}.custom.css (file not found)"
continue
fi
# Find variables in base that are NOT in client custom file
while read -r var; do
[ -z "$var" ] && continue
if ! echo "$CLIENT_CSS" | grep -qF "$var"; then
MISSING="$MISSING\n- \`${var}\` missing from ${theme}.custom.css"
fi
done < /tmp/all_vars.txt
done
if [ -n "$MISSING" ]; then
BODY="Your theme custom files are missing CSS variables defined in MokoOnyx base.\n\n## Missing Variables\n${MISSING}\n\n## Action\n\nAdd these variables to your \`dark.custom.css\` and/or \`light.custom.css\` with appropriate values for your theme.\n\nBase CSS reference: ${{ github.server_url }}/MokoConsulting/MokoOnyx/src/branch/main/src/media/templates/site/mokoonyx/css"
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
"${API}/repos/${repo}/issues" \
-d "$(jq -n --arg t "chore: CSS variables out of sync with MokoOnyx" --arg b "$BODY" '{title:$t,body:$b}')" \
-o /dev/null && echo "Issue created: ${repo}"
else
echo " All variables present"
fi
done
+96
View File
@@ -0,0 +1,96 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
# +========================================================================+
# | SECRET SCANNING |
# +========================================================================+
# | |
# | Scans commits for leaked secrets using Gitleaks. |
# | |
# | - PR scan: only new commits in the PR |
# | - Scheduled: full repo scan weekly |
# | - Alerts via ntfy on findings |
# | |
# +========================================================================+
name: Secret Scanning
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Scan for secrets
id: scan
run: |
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
if [ "${{ github.event_name }}" = "pull_request" ]; then
# Scan only PR commits
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
else
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
fi
if gitleaks detect $ARGS 2>&1; then
echo "result=clean" >> "$GITHUB_OUTPUT"
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "result=found" >> "$GITHUB_OUTPUT"
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Notify on findings
if: failure() && steps.scan.outputs.result == 'found'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} — secrets detected in code" \
-H "Tags: rotating_light,key" \
-H "Priority: urgent" \
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+90
View File
@@ -0,0 +1,90 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Enforces branch merge policy:
# feature/* → dev only
# fix/* → dev only
# hotfix/* → dev or main (emergency)
# dev → main only
# alpha/* → dev only
# beta/* → dev only
# rc/* → main only
name: Branch Policy Check
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
check-target:
name: Verify merge target
runs-on: ubuntu-latest
steps:
- name: Check branch policy
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
alpha/*|beta/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Pre-release branches must target 'dev', not '${BASE}'"
fi
;;
rc/*)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Release candidate branches must target 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo ""
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
@@ -278,7 +278,7 @@ jobs:
f.write(content)
PYEOF
# Commit and push
# Commit and push to current branch
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
@@ -287,6 +287,28 @@ jobs:
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
run: |
CURRENT_BRANCH="${{ github.ref_name }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
# Sync updates.xml to main and dev (whichever isn't current)
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml → ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/cascade-dev.yml.template
# VERSION: 02.00.00
# BRIEF: Forward-merge main → all open branches after every push to main
#
# +========================================================================+
# | CASCADE MAIN → ALL BRANCHES |
# +========================================================================+
# | |
# | Triggers on every push to main (PR merges, bot commits, etc.) |
# | |
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
# | 2. For each: create PR (main → branch), auto-merge if clean |
# | 3. On conflict: leave PR open for manual resolution |
# | |
# +========================================================================+
name: "Universal: Cascade Main → Dev"
on:
push:
branches:
- main
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
pull-requests: write
jobs:
cascade:
name: Cascade main → branches
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip cascade]')
steps:
- name: Discover target branches
id: branches
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Fetch all branches (paginated)
PAGE=1
ALL_BRANCHES=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/branches?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
PAGE=$((PAGE + 1))
done
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
TARGETS=""
for BRANCH in $ALL_BRANCHES; do
case "$BRANCH" in
dev|dev/*|rc/*|beta/*|alpha/*)
TARGETS="$TARGETS $BRANCH"
;;
esac
done
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
if [ -z "$TARGETS" ]; then
echo "targets=" >> "$GITHUB_OUTPUT"
echo "️ No cascade target branches found"
else
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$TARGETS" | wc -w)
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
fi
- name: Cascade to all target branches
if: steps.branches.outputs.targets != ''
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
SHORT_SHA="${GITHUB_SHA:0:7}"
TARGETS="${{ steps.branches.outputs.targets }}"
SUCCESS=0
CONFLICTS=0
SKIPPED=0
FAILED=0
for BRANCH in $TARGETS; do
echo ""
echo "═══ main → ${BRANCH} ═══"
# Check if branch is already up to date
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
RESPONSE=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/compare/${ENCODED_BRANCH}...main")
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
if [ "$AHEAD" -eq 0 ]; then
echo " ✅ Already up to date"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo " ️ main is ${AHEAD} commit(s) ahead"
# Check for existing cascade PR
EXISTING=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
PR_NUMBER=""
if [ "$EXISTING_COUNT" -gt 0 ]; then
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
echo " ️ Reusing existing PR #${PR_NUMBER}"
else
# Create cascade PR
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
\"head\": \"main\",
\"base\": \"${BRANCH}\"
}" \
"${API}/pulls")
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
BODY=$(echo "$PR_RESPONSE" | sed '$d')
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
FAILED=$((FAILED + 1))
continue
fi
echo " ✅ Created PR #${PR_NUMBER}"
fi
# Try auto-merge
PR_DATA=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/pulls/${PR_NUMBER}")
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
if [ "$MERGEABLE" != "true" ]; then
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
continue
fi
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"Do\": \"merge\",
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
\"delete_branch_after_merge\": false
}" \
"${API}/pulls/${PR_NUMBER}/merge")
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
echo " ✅ Merged — ${BRANCH} is in sync"
SUCCESS=$((SUCCESS + 1))
else
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
fi
done
# Summary
echo ""
echo "════════════════════════════════════════"
echo " ✅ Merged: ${SUCCESS}"
echo " ⚠️ Conflicts: ${CONFLICTS}"
echo " ⏭️ Up to date: ${SKIPPED}"
echo " ❌ Failed: ${FAILED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
exit 1
fi
+450
View File
@@ -0,0 +1,450 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow.Template
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/joomla/ci-joomla.yml.template
# VERSION: 04.06.00
# BRIEF: CI workflow for Joomla extensions — lint, validate, test
name: "Joomla: Extension CI"
on:
pull_request:
branches:
- main
- 'dev/**'
workflow_dispatch:
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
lint-and-validate:
name: Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Clone MokoStandards
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
--no-interaction \
--prefer-dist \
--optimize-autoloader
else
echo "No composer.json found — skipping dependency install"
fi
- name: PHP syntax check
run: |
ERRORS=0
for DIR in src/ htdocs/; do
if [ -d "$DIR" ]; then
FOUND=1
while IFS= read -r -d '' FILE; do
OUTPUT=$(php -l "$FILE" 2>&1)
if echo "$OUTPUT" | grep -q "Parse error"; then
echo "::error file=${FILE}::${OUTPUT}"
ERRORS=$((ERRORS + 1))
fi
done < <(find "$DIR" -name "*.php" -print0)
fi
done
echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
fi
- name: XML manifest validation
run: |
echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find the extension manifest (XML with <extension tag)
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No Joomla extension manifest found (XML file with \`<extension\` tag)." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
# Validate well-formed XML
php -r "
\$xml = @simplexml_load_file('$MANIFEST');
if (\$xml === false) {
echo 'INVALID';
exit(1);
}
echo 'VALID';
" > /tmp/xml_result 2>&1
XML_RESULT=$(cat /tmp/xml_result)
if [ "$XML_RESULT" != "VALID" ]; then
echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
fi
# Check required tags: name, version, author, namespace (Joomla 5+)
for TAG in name version author namespace; do
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
fi
done
fi
if [ "${ERRORS}" -gt 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Check language files referenced in manifest
run: |
echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -n "$MANIFEST" ]; then
# Extract language file references from manifest
LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
if [ -z "$LANG_FILES" ]; then
echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY
else
while IFS= read -r LANG_FILE; do
LANG_FILE=$(echo "$LANG_FILE" | xargs)
if [ -z "$LANG_FILE" ]; then
continue
fi
# Check in common locations
FOUND=0
for BASE in "." "src" "htdocs"; do
if [ -f "${BASE}/${LANG_FILE}" ]; then
FOUND=1
break
fi
done
if [ "$FOUND" -eq 0 ]; then
echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
fi
done <<< "$LANG_FILES"
fi
else
echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY
fi
if [ "${ERRORS}" -gt 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Check index.html files in directories
run: |
echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY
MISSING=0
CHECKED=0
for DIR in src/ htdocs/; do
if [ -d "$DIR" ]; then
while IFS= read -r -d '' SUBDIR; do
CHECKED=$((CHECKED + 1))
if [ ! -f "${SUBDIR}/index.html" ]; then
echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY
MISSING=$((MISSING + 1))
fi
done < <(find "$DIR" -type d -print0)
fi
done
if [ "${CHECKED}" -eq 0 ]; then
echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
elif [ "${MISSING}" -gt 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
fi
release-readiness:
name: Release Readiness Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.base_ref == 'main'
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Validate release readiness
run: |
echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Extract version from README.md
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
if [ -z "$README_VERSION" ]; then
echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
fi
# Find the extension manifest
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
# Check <version> matches README VERSION
MANIFEST_VERSION=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
if [ -z "$MANIFEST_VERSION" ]; then
echo "No \`<version>\` tag in manifest." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then
echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY
fi
# Check extension type, element, client attributes
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ -z "$EXT_TYPE" ]; then
echo "Missing \`type\` attribute on \`<extension>\` tag." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY
fi
# Element check (component/module/plugin name)
HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0")
if [ "$HAS_ELEMENT" -eq 0 ]; then
echo "Missing \`<element>\` or \`<name>\` in manifest." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
# Client attribute for site/admin modules and plugins
if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then
HAS_CLIENT=$(grep -cP '<extension[^>]*\bclient=' "$MANIFEST" 2>/dev/null || echo "0")
if [ "$HAS_CLIENT" -eq 0 ]; then
echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
fi
fi
# Check updates.xml exists
if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then
echo "Update XML present." >> $GITHUB_STEP_SUMMARY
else
echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
# Check CHANGELOG.md exists
if [ -f "CHANGELOG.md" ]; then
echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY
else
echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ $ERRORS -gt 0 ]; then
echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY
fi
test:
name: Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
needs: lint-and-validate
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3']
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP ${{ matrix.php }}
run: |
php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
--no-interaction \
--prefer-dist \
--optimize-autoloader
else
echo "No composer.json found — skipping dependency install"
fi
- name: Run tests
run: |
echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log
EXIT=${PIPESTATUS[0]}
if [ $EXIT -eq 0 ]; then
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
else
echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit $EXIT
else
echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY
fi
static-analysis:
name: PHPStan Analysis
runs-on: ubuntu-latest
needs: lint-and-validate
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --optimize-autoloader
fi
- name: Install PHPStan
run: |
if ! command -v vendor/bin/phpstan &> /dev/null; then
composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \
composer global require phpstan/phpstan --no-interaction
fi
- name: Run PHPStan
run: |
echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY
PHPSTAN="vendor/bin/phpstan"
if [ ! -f "$PHPSTAN" ]; then
PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan
fi
# Determine source directory
SRC_DIR=""
for DIR in src/ htdocs/ lib/; do
if [ -d "$DIR" ]; then
SRC_DIR="$DIR"
break
fi
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
exit 0
fi
# Use repo phpstan.neon if present, otherwise use baseline config
ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table"
if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then
echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY
else
ARGS="$ARGS --level=3"
echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY
fi
$PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt
EXIT=${PIPESTATUS[0]}
if [ $EXIT -eq 0 ]; then
echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY
else
ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some")
echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit $EXIT
+87
View File
@@ -0,0 +1,87 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
name: "Universal: Repository Cleanup"
on:
schedule:
- cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC
workflow_dispatch:
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
cleanup:
name: Clean Merged Branches
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
- name: Delete merged branches
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0
for BRANCH in $BRANCHES; do
# Skip protected branches
case "$BRANCH" in
main|master|develop|release/*|hotfix/*) continue ;;
esac
# Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1))
fi
done
echo "Deleted ${DELETED} merged branch(es)"
- name: Clean old workflow runs
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0
for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
echo "Deleted ${DELETED} old workflow run(s)"
+126
View File
@@ -0,0 +1,126 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+96
View File
@@ -0,0 +1,96 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
# +========================================================================+
# | SECRET SCANNING |
# +========================================================================+
# | |
# | Scans commits for leaked secrets using Gitleaks. |
# | |
# | - PR scan: only new commits in the PR |
# | - Scheduled: full repo scan weekly |
# | - Alerts via ntfy on findings |
# | |
# +========================================================================+
name: "Universal: Secret Scanning"
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Scan for secrets
id: scan
run: |
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
if [ "${{ github.event_name }}" = "pull_request" ]; then
# Scan only PR commits
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
else
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
fi
if gitleaks detect $ARGS 2>&1; then
echo "result=clean" >> "$GITHUB_OUTPUT"
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "result=found" >> "$GITHUB_OUTPUT"
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Notify on findings
if: failure() && steps.scan.outputs.result == 'found'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} — secrets detected in code" \
-H "Tags: rotating_light,key" \
-H "Priority: urgent" \
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+71
View File
@@ -0,0 +1,71 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications"
on:
workflow_run:
workflows:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
- "Cascade Main → Dev"
types:
- completed
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
jobs:
notify:
name: Send Notification
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure'
steps:
- name: Notify on success (releases only)
if: >-
github.event.workflow_run.conclusion == 'success' &&
contains(github.event.workflow_run.name, 'Release')
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} released" \
-H "Tags: white_check_mark,package" \
-H "Priority: default" \
-H "Click: ${URL}" \
-d "${WORKFLOW} completed successfully." \
"${NTFY_URL}/${NTFY_TOPIC}"
- name: Notify on failure
if: github.event.workflow_run.conclusion == 'failure'
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} workflow failed" \
-H "Tags: x,warning" \
-H "Priority: high" \
-H "Click: ${URL}" \
-d "${WORKFLOW} failed. Check the run for details." \
"${NTFY_URL}/${NTFY_TOPIC}"
+196
View File
@@ -0,0 +1,196 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
alpha/*|beta/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Pre-release branches must target 'dev', not '${BASE}'"
fi
;;
rc/*)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Release candidate branches must target 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
+384
View File
@@ -0,0 +1,384 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.00.00
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
workflow_dispatch:
inputs:
stability:
description: 'Pre-release channel'
required: true
type: choice
options:
- development
- alpha
- beta
- release-candidate
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability }})"
runs-on: release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
fi
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
- name: Resolve metadata
id: meta
run: |
STABILITY="${{ inputs.stability }}"
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Read and bump patch version (with rollover)
CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
[ -z "$CURRENT" ] && CURRENT="00.00.00"
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
MINOR=$(echo "$CURRENT" | cut -d. -f2)
PATCH=$(echo "$CURRENT" | cut -d. -f3)
# Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
NEW_PATCH=$((10#$PATCH + 1))
NEW_MINOR=$((10#$MINOR))
NEW_MAJOR=$((10#$MAJOR))
if [ $NEW_PATCH -gt 99 ]; then
NEW_PATCH=0
NEW_MINOR=$((NEW_MINOR + 1))
fi
if [ $NEW_MINOR -gt 99 ]; then
NEW_MINOR=0
NEW_MAJOR=$((NEW_MAJOR + 1))
fi
VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
TODAY=$(date +%Y-%m-%d)
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
# Update README.md
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
# Update platform-specific manifest
PLATFORM="${{ steps.platform.outputs.platform }}"
MANIFEST="${{ steps.platform.outputs.manifest }}"
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
case "$PLATFORM" in
joomla)
if [ -n "$MANIFEST" ]; then
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
fi
;;
*) ;;
esac
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element (platform-aware)
case "$PLATFORM" in
joomla)
MANIFEST="${{ steps.platform.outputs.manifest }}"
EXT_ELEMENT=""
if [ -n "$MANIFEST" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
dolibarr)
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
if [ -n "$MOD_FILE" ]; then
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
*)
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
;;
esac
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Build package
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::error::No src/ or htdocs/ directory"
exit 1
fi
mkdir -p build/package
rsync -a \
--exclude='sftp-config*' \
--exclude='.ftpignore' \
--exclude='*.ppk' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='.env*' \
--exclude='*.local' \
--exclude='.build-trigger' \
"${SOURCE_DIR}/" build/package/
- name: Create ZIP
id: zip
run: |
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
cd build/package
zip -r "../${ZIP_NAME}" .
cd ..
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
- name: Create or replace Gitea release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
BRANCH=$(git branch --show-current)
BODY="## ${VERSION} ($(date +%Y-%m-%d))
**Channel:** ${STABILITY}
**SHA-256:** \`${SHA256}\`"
# Delete existing release
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/tags/${TAG}" 2>/dev/null || true
fi
# Create release
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/releases" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg target "$BRANCH" \
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
--arg body "$BODY" \
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
)" | jq -r '.id')
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
# Upload ZIP
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
--data-binary "@build/${ZIP_NAME}"
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
STABILITY="${{ steps.meta.outputs.stability }}"
VERSION="${{ steps.meta.outputs.version }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
TAG="${{ steps.meta.outputs.tag }}"
DATE=$(date +%Y-%m-%d)
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
python3 << 'PYEOF'
import re, os
stability = os.environ["PY_STABILITY"]
version = os.environ["PY_VERSION"]
sha256 = os.environ["PY_SHA256"]
zip_name = os.environ["PY_ZIP_NAME"]
tag = os.environ["PY_TAG"]
date = os.environ["PY_DATE"]
gitea_org = os.environ["PY_GITEA_ORG"]
gitea_repo = os.environ["PY_GITEA_REPO"]
download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
with open("updates.xml", "r") as f:
content = f.read()
# Map stability to XML tag name
tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
xml_tag = tag_map.get(stability, stability)
pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
match = re.search(pattern, content, re.DOTALL)
if match:
block = match.group(1)
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
if "<sha256>" in updated:
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
else:
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\g<1>{download_url}\g<2>", updated)
content = content.replace(block, updated)
print(f"Updated {xml_tag} channel: version={version}")
else:
print(f"WARNING: No <tag>{xml_tag}</tag> block in updates.xml")
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit and push to current branch
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
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]"
# Sync updates.xml to main and dev (whichever isn't current)
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml → ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.GA_TOKEN }}"
STABILITY="${{ steps.meta.outputs.stability }}"
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
case "$STABILITY" in
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
beta) TAGS_TO_DELETE="alpha development" ;;
alpha) TAGS_TO_DELETE="development" ;;
*) TAGS_TO_DELETE="" ;;
esac
[ -z "$TAGS_TO_DELETE" ] && exit 0
for TAG in $TAGS_TO_DELETE; do
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
fi
done
+766
View File
@@ -0,0 +1,766 @@
# ============================================================================
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 04.06.00
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
name: "Joomla: Repo Health"
concurrency:
group: repo-health-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
profile:
description: 'Validation profile: all, release, scripts, or repo'
required: true
default: all
type: choice
options:
- all
- release
- scripts
- repo
pull_request:
push:
permissions:
contents: read
env:
# Release policy - Repository Variables Only
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
# Scripts governance policy
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
# Repo health policy
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
REPO_DISALLOWED_DIRS:
REPO_DISALLOWED_FILES: TODO.md,todo.md
# Extended checks toggles
EXTENDED_CHECKS: "true"
# File / directory variables
DOCS_INDEX: docs/docs-index.md
SCRIPT_DIR: scripts
WORKFLOWS_DIR: .gitea/workflows
SHELLCHECK_PATTERN: '*.sh'
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
access_check:
name: Access control
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
allowed: ${{ steps.perm.outputs.allowed }}
permission: ${{ steps.perm.outputs.permission }}
steps:
- name: Check actor permission (admin only)
id: perm
env:
TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
ALLOWED=false
PERMISSION=unknown
METHOD=""
# Hardcoded authorized users — always allowed
case "$ACTOR" in
jmiller|gitea-actions[bot])
ALLOWED=true
PERMISSION=admin
METHOD="hardcoded allowlist"
;;
*)
# Detect platform and check permissions via API
API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
ALLOWED=true
fi
METHOD="collaborator API"
;;
esac
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
{
echo "## Access Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${ALLOWED} |"
echo ""
if [ "$ALLOWED" = "true" ]; then
echo "${ACTOR} authorized (${METHOD})"
else
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
fi
} >> "${GITHUB_STEP_SUMMARY}"
- name: Deny execution when not permitted
if: ${{ steps.perm.outputs.allowed != 'true' }}
run: |
set -euo pipefail
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
exit 1
release_config:
name: Release configuration
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Guardrails release vars
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes release validation'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
missing=()
missing_optional=()
for k in "${required[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing+=("${k}")
done
for k in "${optional[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing_optional+=("${k}")
done
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Variable | Status |'
printf '%s\n' '|---|---|'
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repository variables'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#missing[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repository variables'
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
{
printf '%s\n' '### Repository variables validation result'
printf '%s\n' 'Status: OK'
printf '%s\n' 'All required repository variables present.'
printf '%s\n' ''
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
scripts_governance:
name: Scripts governance
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Scripts folder checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes scripts governance'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [ ! -d "${SCRIPT_DIR}" ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' 'Status: OK (advisory)'
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
missing_dirs=()
unapproved_dirs=()
for d in "${required_dirs[@]}"; do
req="${d%/}"
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
done
while IFS= read -r d; do
allowed=false
for a in "${allowed_dirs[@]}"; do
a_norm="${a%/}"
[ "${d%/}" = "${a_norm}" ] && allowed=true
done
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Area | Status | Notes |'
printf '%s\n' '|---|---|---|'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
else
printf '%s\n' '| Required directories | OK | All required subfolders present |'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
else
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
fi
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
printf '\n'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Missing required script directories:'
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Missing required script directories: none.'
printf '\n'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Unapproved script directories detected:'
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Unapproved script directories detected: none.'
printf '\n'
fi
printf '%s\n' 'Scripts governance completed in advisory mode.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
repo_health:
name: Repository health
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Repository health checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes repository health'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
# Source directory: src/ or htdocs/ (either is valid)
if [ -d "src" ]; then
SOURCE_DIR="src"
elif [ -d "htdocs" ]; then
SOURCE_DIR="htdocs"
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%/}"
[ ! -d "${d}" ] && missing_required+=("${item}")
else
[ ! -f "${item}" ] && missing_required+=("${item}")
fi
done
for f in "${optional_files[@]}"; do
if printf '%s' "${f}" | grep -q '/$'; then
d="${f%/}"
[ ! -d "${d}" ] && missing_optional+=("${f}")
else
[ ! -f "${f}" ] && missing_optional+=("${f}")
fi
done
for d in "${disallowed_dirs[@]}"; do
d_norm="${d%/}"
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
done
for f in "${disallowed_files[@]}"; do
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
done
git fetch origin --prune
dev_paths=()
dev_branches=()
while IFS= read -r b; do
name="${b#origin/}"
if [ "${name}" = 'dev' ]; then
dev_branches+=("${name}")
else
dev_paths+=("${name}")
fi
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
if [ "${#dev_paths[@]}" -eq 0 ]; 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>)")
fi
content_warnings=()
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
fi
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
fi
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
content_warnings+=("LICENSE does not look like a GPL text")
fi
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
content_warnings+=("README.md missing expected brand keyword")
fi
export PROFILE_RAW="${profile}"
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
report_json="$(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
)"
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Metric | Value |'
printf '%s\n' '|---|---|'
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
printf '\n'
printf '%s\n' '### Guardrails report (JSON)'
printf '%s\n' '```json'
printf '%s\n' "${report_json}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_required[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repo artifacts'
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repo artifacts'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#content_warnings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Repo content warnings'
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
# -- Joomla-specific checks --
joomla_findings=()
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
if [ -z "${MANIFEST}" ]; then
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
else
if ! grep -qP '<version>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <version> tag missing")
fi
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
joomla_findings+=("XML manifest: type attribute missing or invalid")
fi
if ! grep -qP '<name>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <name> tag missing")
fi
if ! grep -qP '<author>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <author> tag missing")
fi
if ! grep -qP '<namespace' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
fi
fi
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
if [ "${INI_COUNT}" -eq 0 ]; then
joomla_findings+=("No .ini language files found")
fi
if [ ! -f 'updates.xml' ]; then
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
fi
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 [ "${#joomla_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' '| Check | Status |'
printf '%s\n' '|---|---|'
for f in "${joomla_findings[@]}"; do
printf '%s\n' "| ${f} | Warning |"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
else
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' 'All Joomla-specific checks passed.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
extended_enabled="${EXTENDED_CHECKS:-true}"
extended_findings=()
if [ "${extended_enabled}" = 'true' ]; then
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
:
else
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
fi
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
if [ -n "${bad_refs}" ]; then
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
{
printf '%s\n' '### Workflow pinning advisory'
printf '%s\n' 'Found uses: entries pinned to main/master:'
printf '%s\n' '```'
printf '%s\n' "${bad_refs}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -f "${DOCS_INDEX}" ]; then
missing_links="$(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
)"
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}"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -d "${SCRIPT_DIR}" ]; then
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y shellcheck >/dev/null
fi
sc_out=''
while IFS= read -r shf; do
[ -z "${shf}" ] && continue
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
if [ -n "${out_one}" ]; then
sc_out="${sc_out}${out_one}\n"
fi
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
if [ -n "${sc_out}" ]; then
extended_findings+=("ShellCheck warnings detected (advisory)")
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
{
printf '%s\n' '### ShellCheck (advisory)'
printf '%s\n' '```'
printf '%s\n' "${sc_head}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
spdx_missing=()
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
spdx_args=()
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
while IFS= read -r f; do
[ -z "${f}" ] && continue
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
spdx_missing+=("${f}")
fi
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
if [ "${#spdx_missing[@]}" -gt 0 ]; then
extended_findings+=("SPDX header missing in some tracked files (advisory)")
{
printf '%s\n' '### SPDX header advisory'
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
stale_cutoff_days=180
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
if [ -n "${stale_branches}" ]; then
extended_findings+=("Stale remote branches detected (advisory)")
{
printf '%s\n' '### Git hygiene advisory'
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
{
printf '%s\n' '### Guardrails coverage matrix'
printf '%s\n' '| Domain | Status | Notes |'
printf '%s\n' '|---|---|---|'
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
printf '%s\n' '| Release variables | OK | Repository variables validation |'
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
if [ "${extended_enabled}" = 'true' ]; then
if [ "${#extended_findings[@]}" -gt 0 ]; then
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
else
printf '%s\n' '| Extended checks | OK | No findings |'
fi
else
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
fi
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Extended findings (advisory)'
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
+82
View File
@@ -0,0 +1,82 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+464
View File
@@ -0,0 +1,464 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Joomla
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/joomla/update-server.yml.template
# VERSION: 04.06.00
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
#
# Writes updates.xml with multiple <update> entries:
# - <tag>stable</tag> on push to main (from auto-release)
# - <tag>rc</tag> on push to rc/**
# - <tag>development</tag> on push to dev or dev/**
#
# Joomla filters by user's "Minimum Stability" setting.
name: "Joomla: Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update updates.xml
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GA_TOKEN }}
fetch-depth: 0
- name: Setup MokoStandards tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Generate updates.xml entry
id: update
run: |
BRANCH="${{ github.ref_name }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Auto-bump patch on all branches (dev, alpha, beta, rc)
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
if [ -n "$BUMPED" ]; then
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
git add -A
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
git push 2>/dev/null || true
fi
# Determine stability from branch or input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
STABILITY="development"
else
STABILITY="stable"
fi
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
# Parse manifest (portable — no grep -P)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "No Joomla manifest found — skipping"
exit 0
fi
# Extract fields using sed (works on all runners)
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
# Fallbacks
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
# Derive element if not in manifest: try XML filename, then repo name
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
# Use manifest version if README version is empty
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
CLIENT_TAG=""
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
FOLDER_TAG=""
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
PHP_TAG=""
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
# Version suffix for non-stable
DISPLAY_VERSION="$VERSION"
case "$STABILITY" in
development) DISPLAY_VERSION="${VERSION}-dev" ;;
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
esac
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
# Each stability level has its own release tag
case "$STABILITY" in
development) RELEASE_TAG="development" ;;
alpha) RELEASE_TAG="alpha" ;;
beta) RELEASE_TAG="beta" ;;
rc) RELEASE_TAG="release-candidate" ;;
*) RELEASE_TAG="v${MAJOR}" ;;
esac
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
# -- Build install packages (ZIP + tar.gz) --------------------
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ -d "$SOURCE_DIR" ]; then
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
cd "$SOURCE_DIR"
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
cd ..
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
--exclude='.ftpignore' --exclude='sftp-config*' \
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
# Ensure release exists on Gitea
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
# Create release
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/json" \
"${API_BASE}/releases" \
-d "$(python3 -c "import json; print(json.dumps({
'tag_name': '${RELEASE_TAG}',
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
'body': '${STABILITY} release',
'prerelease': True,
'target_commitish': 'main'
}))")" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
fi
if [ -n "$RELEASE_ID" ]; then
# Delete existing assets with same name before uploading
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
ASSET_ID=$(echo "$ASSETS" | python3 -c "
import sys,json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '${ASSET_FILE}':
print(a['id']); break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
fi
done
# Upload both formats
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${PACKAGE_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${TAR_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
fi
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
else
SHA256=""
fi
# -- Build the new entry (canonical format matching release.yml) --
NEW_ENTRY=""
NEW_ENTRY="${NEW_ENTRY} <update>\n"
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
NEW_ENTRY="${NEW_ENTRY} </update>"
# -- Write new entry to temp file --------------------------------
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
# -- Merge into updates.xml ----------------------------------------
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
TARGETS=""
for entry in $CASCADE_MAP; do
key="${entry%%:*}"
vals="${entry#*:}"
if [ "$key" = "${STABILITY}" ]; then
TARGETS="$vals"
break
fi
done
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
echo "Cascade: ${STABILITY} → ${TARGETS}"
# Create updates.xml if missing
if [ ! -f "updates.xml" ]; then
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
printf '%s\n' "<updates>" >> updates.xml
printf '%s\n' "</updates>" >> updates.xml
fi
# Update existing blocks or create missing ones
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
python3 << 'PYEOF'
import re, os
targets = os.environ["PY_TARGETS"].split(",")
version = os.environ["PY_VERSION"]
date = os.environ["PY_DATE"]
with open("updates.xml") as f:
content = f.read()
with open("/tmp/new_entry.xml") as f:
new_entry_template = f.read()
for tag in targets:
tag = tag.strip()
# Build entry with this tag's name
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
# Try to find existing block (handles both single-line and multi-line <tags>)
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
match = re.search(block_pattern, content, re.DOTALL)
if match:
# Update in place — replace entire block
content = content.replace(match.group(1), new_entry.strip())
print(f" UPDATED: <tag>{tag}</tag> → {version}")
else:
# Create — insert before </updates>
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
print(f" CREATED: <tag>{tag}</tag> → {version}")
# Clean up excessive blank lines
content = re.sub(r"\n{3,}", "\n\n", content)
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
# -- Sync updates.xml to main (for non-main branches) ----------------------
- name: Sync updates.xml to main
if: github.ref_name != 'main'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GA_TOKEN="${{ secrets.GA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
CONTENT=$(base64 -w0 updates.xml)
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/contents/updates.xml" \
-d "$(python3 -c "import json; print(json.dumps({
'content': '${CONTENT}',
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
'branch': 'main'
}))")" > /dev/null 2>&1 \
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
else
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# -- Permission check: admin or maintain role required --------
ACTOR="${{ github.actor }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
+17 -9
View File
@@ -24,11 +24,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Smart Visitor Detection** — Pushes anonymised visitor properties (login status, user group, page type) to the dataLayer for Google Analytics / Tag Manager. Sets GA4 `user_properties` for persistent session-scoped dimensions. No PII is sent. Default enabled when GTM or GA4 is active.
- **Auto-cascade workflow** — Forward-merges `main``dev` after every push; auto-creates a PR on conflict
- **Component/print-view stylesheet** — Dedicated `component.css` replaces `template.css` in the component view with print-optimised styles using theme variables
- **Print-view GA4 tracking** — Component view sends `content_group=print_view` to Google Analytics for tracking print/modal usage
- **Custom light theme in component view** — Component view now loads `light.custom.css` when configured
- **Changelog auto-bump in auto-release** — `## [Unreleased]` is automatically promoted to the release version on stable release, with a fresh `## [Unreleased]` section inserted above
## [03.10.00] - 2026-04-18 — Bridge Release (MokoOnyx → MokoOnyx)
### Changed
- **Custom head params replaced with user files** — Removed `custom_head_start` / `custom_head_end` template params in favour of `user.css` and `user.js` (loaded via Web Asset Manager)
- **User override files added to .gitignore** — `user.css` and `user.js` are client-repo only; not committed to the template repo
### Removed
- **Migration tab** — Removed MokoCassiopeia migration fieldset and associated language strings from template params
- **Migration description** — Removed migration callout and "formerly MokoCassiopeia" reference from template description
- **Custom head fields** — Removed `custom_head_start` / `custom_head_end` fields and `Custom Code` fieldset from template configuration
## [03.10.00] - 2026-04-18
### Important
- **Template Rename** — MokoOnyx is being renamed to **MokoOnyx**. This bridge release automatically migrates your template settings, menu assignments, and files to the new name. MokoOnyx can be safely uninstalled after this update.
- **Template Consolidation** — This release finalised the MokoOnyx identity, adding automatic migration from legacy MokoCassiopeia installations. Settings, menu assignments, and files are imported on first page load.
### Added
- **Offline page redesign** — Full-viewport background from Joomla offline_image or header background, glass card overlay, centered logo with glow, login accordion, copyright footer
@@ -38,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Favicon multi-format support** — Now handles PNG, JPEG, GIF, WebP, BMP (not just PNG)
- **Theme variables** — `--theme-fab-bg`, `--theme-fab-color`, `--theme-fab-btn-bg`, `--theme-fab-border`, `--offline-card-bg`
- **Footer CSS variables** — Added to CSS Variables reference tab
- **Bridge migration script** — `helper/bridge.php` handles automatic MokoOnyx → MokoOnyx migration
- **Bridge migration script** — `helper/bridge.php` handles automatic MokoCassiopeia → MokoOnyx migration
- **Dedicated release runner** — Release workflows run on isolated `release` label runner
- **Runner fleet** — 3 CI + 1 release runner (12 concurrent jobs)
@@ -67,10 +80,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [Unreleased] - 2026-04-02
## [03.09.03] - 2026-04-02
### Added
- **Favicon configuration** — New "Favicon" tab in template config; upload a PNG and all favicon sizes are auto-generated via PHP GD (ICO, Apple Touch Icon 180px, Android Chrome 192/512px, site.webmanifest)
- **Module overrides** — 11 new `default.php` layout overrides for Joomla core modules: `mod_custom`, `mod_articles_latest`, `mod_articles_popular`, `mod_articles_news`, `mod_articles_category`, `mod_breadcrumbs`, `mod_footer`, `mod_login`, `mod_finder`, `mod_tags_popular`, `mod_tags_similar`, `mod_related_items`
- **Module title support** — All module overrides respect `$module->showtitle`, `header_tag`, `header_class`, and `moduleclass_sfx` parameters
@@ -79,10 +91,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Hero mobile breakpoint** — Photo background hidden on mobile (≤767.98px), hero card becomes full-bleed (100dvh, no border-radius)
- **CSS fallback values** — 1365 `var()` calls in template.css now include inline fallback values
- **Card border-radius** — `.card` now has `.25rem` fallback on `var(--card-border-radius)`
- **Usage section in README** — Added missing "Usage" section required by MokoStandards
### Changed
- **Button backgrounds** — `--btn-bg: transparent` changed to `var(--body-bg)` in dark and light themes
- **Offcanvas close button** — `.offcanvas-header .btn-close` now gets `background-color` from `--offcanvas-bg`
- **Custom template sync** — Both `dark.custom.css` and `light.custom.css` now contain all variables from their standard counterparts (was missing 223 variables)
@@ -90,13 +100,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Legacy CSS cleanup** — Removed vendor prefixes (`-webkit-box`, `-ms-flexbox`) from `.overlay` rules, replaced with modern flexbox
### Removed
- **FILE INFORMATION headers** — Stripped DEFGROUP/INGROUP/PATH/VERSION/BRIEF metadata from all PHP, CSS, JS, INI, and HTML files (kept in XML and README per policy)
- **Mobile overrides** — Deleted 26 `mobile.php` layout files and their empty parent directories
- **Joomla-specific gitignore entries** — Removed ~700 lines of Joomla CMS core paths from `.gitignore` (not applicable to a template repository)
### Fixed
- **CI: composer install** — Workflow `standards-compliance.yml` now conditionally runs `composer install` only when `composer.json` exists
- **CI: YAML syntax** — Fixed invalid YAML in `auto-update-sha.yml` caused by multiline commit message in run block
+15 -1
View File
@@ -121,8 +121,22 @@ clean: ## Clean build artifacts
@rm -rf $(BUILD_DIR) $(DIST_DIR)
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
.PHONY: minify
minify: ## Minify CSS/JS assets (requires terser + clean-css)
@echo "$(COLOR_BLUE)Minifying assets...$(COLOR_RESET)"
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
elif [ -f "scripts/minify.js" ]; then \
node scripts/minify.js; \
else \
echo "$(COLOR_YELLOW)⚠ No minify script found$(COLOR_RESET)"; \
fi
.PHONY: build
build: clean ## Build template installable ZIP from src/
build: clean minify ## Build template installable ZIP from src/
@echo "$(COLOR_BLUE)Building $(EXTENSION_NAME) v$(EXTENSION_VERSION)...$(COLOR_RESET)"
@mkdir -p $(BUILD_DIR)/package $(DIST_DIR)
@cp -r $(SRC_DIR)/* $(BUILD_DIR)/package/
+110 -46
View File
@@ -1,54 +1,118 @@
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
DEFGROUP: Joomla.Template.Site
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
VERSION: 02.01.00
BRIEF: Documentation for MokoOnyx template
-->
# MokoOnyx
> **MokoOnyx** is the successor to MokoCassiopeia. On install, it automatically migrates your settings, content references, and custom files. After installing, MokoCassiopeia can be safely uninstalled.
MokoOnyx - Joomla site template (successor to MokoCassiopeia)
**A Modern, Lightweight Joomla Template Based on Cassiopeia**
![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square) ![Wiki](https://img.shields.io/badge/wiki-MokoOnyx-blue?style=flat-square)
[![Version](https://img.shields.io/gitea/v/release/MokoConsulting/MokoOnyx?gitea_url=https%3A%2F%2Fgit.mokoconsulting.tech&logo=gitea&logoColor=white&label=version)](https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/stable)
[![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green.svg?logo=gnu&logoColor=white)](LICENSE)
[![Joomla](https://img.shields.io/badge/Joomla-5.x%20%7C%206.x-red.svg?logo=joomla&logoColor=white)](https://www.joomla.org)
[![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4.svg?logo=php&logoColor=white)](https://www.php.net)
MokoOnyx is a modern, lightweight enhancement layer built on top of Joomla's Cassiopeia template. It adds **Font Awesome 7**, **Bootstrap 5** helpers, an automatic **Table of Contents (TOC)** utility, advanced **Dark Mode** theming, and optional integrations for **Google Tag Manager** and **Google Analytics (GA4)** -- all while maintaining minimal core template overrides for maximum upgrade compatibility.
## Features
- **Built on Cassiopeia**: Extends Joomla's default template with minimal overrides
- **Font Awesome 7**: Fully integrated into Joomla's asset manager with 2,000+ icons
- **Bootstrap 5**: Extended utility classes and responsive grid system
- **Template Overrides**: Includes overrides for all core Joomla modules, Community Builder, and DPCalendar
- **Dark Mode Support**: Built-in light/dark mode toggle with system preference detection
- **Google Tag Manager / GA4**: Optional analytics integrations with smart visitor detection (login status, user group, page type)
- **Table of Contents**: Automatic TOC generation for long articles
## Requirements
- **Joomla**: 5.x or 6.x
- **PHP**: 8.1 or higher
## Installation
Download the latest `mokoonyx-{version}.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases) and install via Joomla's Extension Manager.
## License
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](./LICENSE) file for details.
A modern, lightweight Joomla site template built on Cassiopeia with Font Awesome 7, Bootstrap 5, dark mode, and analytics integrations.
---
**Made with love by [Moko Consulting](https://mokoconsulting.tech)**
| | |
|---|---|
| **Type** | Joomla Site Template |
| **Version** | 02.01.06 |
| **Joomla** | 5.x / 6.x |
| **PHP** | 8.1+ |
| **License** | GPL-3.0-or-later |
| **Replaces** | MokoCassiopeia (auto-migrates on install) |
| **Repository** | [Gitea](https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx) (primary) |
---
## Overview
MokoOnyx is an enhancement layer built on Joomla's Cassiopeia template. It maintains minimal core overrides for maximum upgrade compatibility while adding modern features and integrations.
On install, MokoOnyx automatically migrates settings, content references, and custom files from MokoCassiopeia. After installing, MokoCassiopeia can be safely uninstalled.
---
## Features
| Feature | Description |
|---------|-------------|
| **Font Awesome 7** | Fully integrated into Joomla's asset manager with 2,000+ icons |
| **Bootstrap 5** | Extended utility classes and responsive grid system |
| **Dark Mode** | Built-in light/dark toggle with system preference detection |
| **Table of Contents** | Automatic TOC generation for long articles |
| **GTM / GA4** | Google Tag Manager and Analytics integration with smart visitor detection (login status, user group, page type) |
| **Template Overrides** | Overrides for all core Joomla modules, Community Builder, and DPCalendar |
| **Cassiopeia Base** | Minimal core overrides for maximum Joomla upgrade compatibility |
---
## Installation
1. Download the latest `mokoonyx-{version}.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases)
2. Install via Joomla's Extension Manager (Extensions > Manage > Install)
3. Set MokoOnyx as the default site template (System > Site Templates)
4. If migrating from MokoCassiopeia, settings are auto-migrated on install
### Requirements
- Joomla 5.x or 6.x
- PHP 8.1 or higher
---
## Directory Structure
| Directory | Purpose |
|-----------|---------|
| `html/` | Template overrides for core modules and extensions |
| `css/` | Compiled stylesheets including dark mode |
| `js/` | JavaScript for TOC, dark mode toggle, analytics |
| `images/` | Template images and icons |
| `language/` | Language files |
---
## Configuration
MokoOnyx template parameters are configured in the Joomla admin under System > Site Templates > MokoOnyx.
Key parameters include:
- **Dark Mode**: Enable/disable, set default mode
- **Font Awesome**: Enable/disable icon library loading
- **Google Tag Manager**: GTM container ID
- **GA4**: Measurement ID and tracking options
- **Table of Contents**: Auto-generate TOC for articles with heading threshold
---
## Related Wikis
| Repo | Purpose |
|------|---------|
| [Template-Client-WaaS](https://git.mokoconsulting.tech/MokoConsulting/Template-Client-WaaS/wiki) | Client site template (extends MokoOnyx) |
| [MokoWaaS](https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/wiki) | WaaS system plugin |
| [joomla-api-mcp](https://git.mokoconsulting.tech/MokoConsulting/joomla-api-mcp/wiki) | Joomla Web Services API MCP |
| [deploy-mcp](https://git.mokoconsulting.tech/MokoConsulting/deploy-mcp/wiki) | Git-based deployment MCP |
---
> **[MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki)** -- central standards hub for all Moko Consulting projects.
---
---
## Documentation
Full documentation is available on the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/wiki).
## Contributing
See the wiki for development guidelines and contribution instructions.
## License
This project is licensed under the GNU General Public License v3.0 or later -- see the [LICENSE](LICENSE) file.
---
*[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)*
+21 -4
View File
@@ -47,15 +47,26 @@ $pageclass = $menu !== null ? $menu->getParams()->get('pageclass_sfx', '') : '';
// Template/Media path
$templatePath = 'media/templates/site/mokoonyx';
// Core template CSS
$wa->useStyle('template.base'); // css/template.css
// Component / print-view CSS (replaces template.css for this view)
$wa->useStyle('template.component'); // css/component.css
// Component always uses light theme only (no theme switching)
// Light theme only (no theme switching in component view)
$wa->useStyle('template.light.standard'); // css/theme/light.standard.css
// Load custom light palette if selected in template configuration and file exists
$params_LightColorName = (string) $this->params->get('colorLightName', 'standard');
if ($params_LightColorName === 'custom' && file_exists(JPATH_ROOT . '/media/templates/site/mokoonyx/css/theme/light.custom.css'))
{
$wa->useStyle('template.light.custom');
}
// Load Osaka font for site title
$wa->useStyle('template.font.osaka');
// Load user assets last (after all other styles and scripts)
$wa->useStyle('template.user'); // css/user.css
$wa->useScript('user.js'); // js/user.js
// Brand: logo from params OR siteTitle
// -------------------------------------
$brandHtml = '';
@@ -129,7 +140,7 @@ if ($logoFile !== '') {
});
(function(id){
if (/^G-/.test(id)) {
gtag('config', id, { 'anonymize_ip': true });
gtag('config', id, { 'anonymize_ip': true, 'content_group': 'print_view' });
} else if (/^UA-/.test(id)) {
gtag('config', id, { 'anonymize_ip': true });
console.warn('Using a UA- ID. Universal Analytics is sunset; consider migrating to GA4.');
@@ -137,6 +148,12 @@ if ($logoFile !== '') {
console.warn('Unrecognized Google Analytics ID format:', id);
}
})('<?php echo $gaId; ?>');
gtag('event', 'page_view', {
'page_title': document.title,
'content_group': 'print_view',
'custom_map': {'dimension1': 'template_view'},
'template_view': 'component'
});
</script>
<!-- End Google Analytics -->
<?php endif; ?>
-4
View File
@@ -27,8 +27,6 @@ $params_googletagmanager = $params->get('googletagmanager', false);
$params_googletagmanagerid = $params->get('googletagmanagerid', '');
$params_googleanalytics = $params->get('googleanalytics', false);
$params_googleanalyticsid = $params->get('googleanalyticsid', '');
$params_custom_head_start = $params->get('custom_head_start', '');
$params_custom_head_end = $params->get('custom_head_end', '');
$params_developmentmode = $params->get('developmentmode', false);
// ------------------ Params ------------------
@@ -154,7 +152,6 @@ $wa->useScript('user.js'); // js/user.js
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
<head>
<?php if ($params_custom_head_start !== '') : ?><?php echo $params_custom_head_start; ?><?php endif; ?>
<jdoc:include type="head" />
<script>
@@ -194,7 +191,6 @@ $wa->useScript('user.js'); // js/user.js
});
</script>
<?php if ($params_custom_head_end !== '') : ?><?php echo $params_custom_head_end; ?><?php endif; ?>
</head>
<body data-bs-spy="scroll" data-bs-target="#toc" class="site error-page<?php
echo ($this->direction == 'rtl' ? ' rtl' : '');
+3
View File
@@ -25,6 +25,7 @@ class MokoMinifyHelper
'css/offline.css',
'css/editor.css',
'css/a11y-high-contrast.css',
'css/user.css',
'css/theme/light.standard.css',
'css/theme/dark.standard.css',
'css/theme/light.custom.css',
@@ -33,6 +34,8 @@ class MokoMinifyHelper
private const JS_FILES = [
'js/template.js',
'js/gtm.js',
'js/user.js',
];
/**
+77
View File
@@ -99,6 +99,10 @@ $assocParam = (Associations::isEnabled() && $params->get('show_associations'));
<?php echo $this->item->event->afterDisplayContent; ?>
<?php if (!empty($this->item->jcfields)) : ?>
<?php echo LayoutHelper::render('mokoonyx.article-metadata', ['item' => $this->item]); ?>
<?php endif; ?>
<?php if (isset($info) && ($info == 1 || $info == 2)) : ?>
<?php if ($params->get('show_tags', 1) && !empty($this->item->tags->itemTags)) : ?>
<?php echo LayoutHelper::render('joomla.content.tags', $this->item->tags->itemTags); ?>
@@ -132,4 +136,77 @@ $assocParam = (Associations::isEnabled() && $params->get('show_associations'));
margin-bottom: 1.5rem;
}
}
/* Article Metadata Footer */
.article-metadata-footer {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 2px solid var(--cassiopeia-color-border, #dee2e6);
}
.article-metadata-footer__group {
margin-bottom: 1.25rem;
}
.article-metadata-footer__group:last-child {
margin-bottom: 0;
}
.article-metadata-footer__group-title {
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--cassiopeia-color-text, #212529);
margin-bottom: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--cassiopeia-color-border, #dee2e6);
}
.article-metadata-footer__fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem 1.5rem;
margin: 0;
}
.article-metadata-footer__field {
display: flex;
flex-direction: column;
}
.article-metadata-footer__label {
font-size: 0.75rem;
font-weight: 600;
color: var(--cassiopeia-color-text, #6c757d);
opacity: 0.7;
margin-bottom: 0.125rem;
}
.article-metadata-footer__value {
font-size: 0.875rem;
color: var(--cassiopeia-color-text, #212529);
margin: 0;
word-break: break-word;
}
.article-metadata-footer__value a {
color: var(--cassiopeia-color-link, #0d6efd);
text-decoration: none;
}
.article-metadata-footer__value a:hover {
text-decoration: underline;
}
/* Full-width for textarea fields */
.article-metadata-footer__field[data-field-type="textarea"] {
grid-column: 1 / -1;
}
@media (max-width: 575.98px) {
.article-metadata-footer__fields {
grid-template-columns: 1fr;
}
}
</style>
@@ -90,6 +90,10 @@ $assocParam = (Associations::isEnabled() && $params->get('show_associations'));
<?php echo $this->item->event->afterDisplayContent; ?>
<?php if (!empty($this->item->jcfields)) : ?>
<?php echo LayoutHelper::render('mokoonyx.article-metadata', ['item' => $this->item]); ?>
<?php endif; ?>
<?php if (isset($info) && ($info == 1 || $info == 2)) : ?>
<?php if ($params->get('show_tags', 1) && !empty($this->item->tags->itemTags)) : ?>
<?php echo LayoutHelper::render('joomla.content.tags', $this->item->tags->itemTags); ?>
@@ -132,4 +136,77 @@ $assocParam = (Associations::isEnabled() && $params->get('show_associations'));
margin-bottom: 1.5rem;
}
}
/* Article Metadata Footer */
.article-metadata-footer {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 2px solid var(--cassiopeia-color-border, #dee2e6);
}
.article-metadata-footer__group {
margin-bottom: 1.25rem;
}
.article-metadata-footer__group:last-child {
margin-bottom: 0;
}
.article-metadata-footer__group-title {
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--cassiopeia-color-text, #212529);
margin-bottom: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--cassiopeia-color-border, #dee2e6);
}
.article-metadata-footer__fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem 1.5rem;
margin: 0;
}
.article-metadata-footer__field {
display: flex;
flex-direction: column;
}
.article-metadata-footer__label {
font-size: 0.75rem;
font-weight: 600;
color: var(--cassiopeia-color-text, #6c757d);
opacity: 0.7;
margin-bottom: 0.125rem;
}
.article-metadata-footer__value {
font-size: 0.875rem;
color: var(--cassiopeia-color-text, #212529);
margin: 0;
word-break: break-word;
}
.article-metadata-footer__value a {
color: var(--cassiopeia-color-link, #0d6efd);
text-decoration: none;
}
.article-metadata-footer__value a:hover {
text-decoration: underline;
}
/* Full-width for textarea fields */
.article-metadata-footer__field[data-field-type="textarea"] {
grid-column: 1 / -1;
}
@media (max-width: 575.98px) {
.article-metadata-footer__fields {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,76 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: Joomla.Template.Site
* INGROUP: MokoOnyx.Layouts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
* PATH: /src/html/layouts/mokoonyx/article-metadata.php
* VERSION: 03.09.04
* BRIEF: Article metadata footer layout -- renders jcfields grouped by field group
*/
defined('_JEXEC') or die;
/**
* Layout: mokoonyx.article-metadata
*
* Renders custom fields as a styled metadata footer, mirroring the
* MokoStandards wiki metadata footer pattern. Fields are grouped
* by their field group and rendered dynamically -- adding new fields
* or groups requires no template changes.
*
* @var array $displayData Expects ['item' => ArticleObject]
*/
$item = $displayData['item'] ?? null;
if (!$item || empty($item->jcfields)) {
return;
}
// Group fields by their group title. Fields without a group go under "Article Metadata".
$groups = [];
foreach ($item->jcfields as $field) {
// Skip fields with no value set
if ($field->rawvalue === '' || $field->rawvalue === null) {
continue;
}
$groupTitle = $field->group_title ?: 'Article Metadata';
$groups[$groupTitle][] = $field;
}
if (empty($groups)) {
return;
}
?>
<div class="article-metadata-footer">
<?php foreach ($groups as $groupTitle => $fields) : ?>
<div class="article-metadata-footer__group">
<h6 class="article-metadata-footer__group-title"><?php echo $this->escape($groupTitle); ?></h6>
<dl class="article-metadata-footer__fields">
<?php foreach ($fields as $field) : ?>
<div class="article-metadata-footer__field" data-field-name="<?php echo $this->escape($field->name); ?>" data-field-type="<?php echo $this->escape($field->type); ?>">
<dt class="article-metadata-footer__label"><?php echo $this->escape($field->label); ?></dt>
<dd class="article-metadata-footer__value">
<?php if ($field->type === 'url' && !empty($field->rawvalue)) : ?>
<a href="<?php echo $this->escape($field->rawvalue); ?>" target="_blank" rel="noopener noreferrer"><?php echo $this->escape($field->rawvalue); ?></a>
<?php elseif ($field->type === 'textarea') : ?>
<?php echo nl2br($this->escape($field->rawvalue)); ?>
<?php else : ?>
<?php echo $this->escape($field->rawvalue); ?>
<?php endif; ?>
</dd>
</div>
<?php endforeach; ?>
</dl>
</div>
<?php endforeach; ?>
</div>
+1
View File
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
+9 -19
View File
@@ -38,8 +38,6 @@ $params_googleanalytics = $this->params->get('googleanalytics', false);
$params_googleanalyticsid = $this->params->get('googleanalyticsid', null);
$params_googlesitekey = $this->params->get('googlesitekey', null);
$params_visitordetection = $this->params->get('googlevisitordetection', true);
$params_custom_head_start = $this->params->get('custom_head_start', null);
$params_custom_head_end = $this->params->get('custom_head_end', null);
$params_developmentmode = $this->params->get('developmentmode', false) || $app->get('debug', false);
$params_favicon_source = (string) $this->params->get('favicon_source', '');
@@ -110,14 +108,9 @@ if ($params_favicon_source) {
require_once __DIR__ . '/helper/minify.php';
MokoMinifyHelper::sync(JPATH_ROOT . '/' . $templatePath, (bool) $params_developmentmode);
// Core template CSS + JS — use minified when not in development mode
if ($params_developmentmode) {
$wa->useStyle('template.base'); // css/template.css
$wa->useScript('template.js'); // js/template.js
} else {
$wa->useStyle('template.base.min'); // css/template.min.css
$wa->useScript('template.js.min'); // js/template.min.js
}
// Core template CSS + JS — Joomla auto-serves .min when debug is off
$wa->useStyle('template.base'); // css/template.css (or .min.css)
$wa->useScript('template.js'); // js/template.js (or .min.js)
// Load Osaka font for site title
$wa->useStyle('template.font.osaka');
@@ -227,7 +220,7 @@ if ($faKitCode !== '') {
} else {
// Load local FA7 Free — all.css via WebAsset
// Resolve the actual filesystem path: media dir (Joomla install) or template dir (SFTP deploy)
$faCssFile = $params_developmentmode ? 'vendor/fa7free/css/all.css' : 'vendor/fa7free/css/all.min.css';
$faCssFile = 'vendor/fa7free/css/all.min.css'; // vendor ships minified only
$faCandidates = [
$templatePath . '/' . $faCssFile, // media/templates/site/mokoonyx/...
'templates/site/' . $this->template . '/media/' . $faCssFile, // templates/site/mokoonyx/media/...
@@ -258,19 +251,18 @@ if ($faKitCode !== '') {
$params_leftIcon = htmlspecialchars($this->params->get('drawerLeftIcon', 'fa-solid fa-chevron-left'), ENT_COMPAT, 'UTF-8');
$params_rightIcon = htmlspecialchars($this->params->get('drawerRightIcon', 'fa-solid fa-chevron-right'), ENT_COMPAT, 'UTF-8');
// Load theme palette stylesheets — minified when not in development mode
$suffix = $params_developmentmode ? '' : '.min';
$wa->useStyle('template.light.standard' . $suffix);
$wa->useStyle('template.dark.standard' . $suffix);
// Load theme palette stylesheets — Joomla auto-serves .min when debug is off
$wa->useStyle('template.light.standard');
$wa->useStyle('template.dark.standard');
// Load custom palettes only if selected in template configuration AND files exist
if ($params_LightColorName === 'custom' && file_exists(JPATH_ROOT . '/media/templates/site/mokoonyx/css/theme/light.custom.css'))
{
$wa->useStyle('template.light.custom' . $suffix);
$wa->useStyle('template.light.custom');
}
if ($params_DarkColorName === 'custom' && file_exists(JPATH_ROOT . '/media/templates/site/mokoonyx/css/theme/dark.custom.css'))
{
$wa->useStyle('template.dark.custom' . $suffix);
$wa->useStyle('template.dark.custom');
}
// Load user assets last (after all other styles and scripts)
@@ -280,7 +272,6 @@ $wa->useScript('user.js'); // js/user.js
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
<head>
<?php if (trim($params_custom_head_start)) : ?><?php echo $params_custom_head_start; ?><?php endif; ?>
<jdoc:include type="head" />
<?php if ($faviconHeadTags) : ?>
<?php echo $faviconHeadTags; ?>
@@ -327,7 +318,6 @@ $wa->useScript('user.js'); // js/user.js
</script>
<?php if (trim($params_custom_head_end)) : ?><?php echo $params_custom_head_end; ?><?php endif; ?>
</head>
<body data-bs-spy="scroll" data-bs-target="#toc"
data-theme-fab-enabled="<?php echo $params_theme_fab_enabled ? '1' : '0'; ?>"
+3 -93
View File
@@ -29,9 +29,9 @@
"attributes": {"media": "all"}
},
{
"name": "template.base.min",
"name": "template.component",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/template.min.css",
"uri": "media/templates/site/mokoonyx/css/component.css",
"attributes": {"media": "all"}
},
{
@@ -40,36 +40,18 @@
"uri": "media/templates/site/mokoonyx/css/offline.css",
"attributes": {"media": "all"}
},
{
"name": "template.offline.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/offline.min.css",
"attributes": {"media": "all"}
},
{
"name": "template.user",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/user.css",
"attributes": {"media": "all"}
},
{
"name": "template.user.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/user.min.css",
"attributes": {"media": "all"}
},
{
"name": "user.js",
"type": "script",
"uri": "media/templates/site/mokoonyx/js/user.js",
"attributes": {"defer": true}
},
{
"name": "user.js.min",
"type": "script",
"uri": "media/templates/site/mokoonyx/js/user.min.js",
"attributes": {"defer": true}
},
{
"name": "template.font.osaka",
"type": "style",
@@ -82,60 +64,30 @@
"uri": "media/templates/site/mokoonyx/css/editor.css",
"attributes": {"media": "all"}
},
{
"name": "template.editor.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/editor.min.css",
"attributes": {"media": "all"}
},
{
"name": "template.light.standard",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/light.standard.css",
"attributes": {"media": "all"}
},
{
"name": "template.light.standard.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/light.standard.min.css",
"attributes": {"media": "all"}
},
{
"name": "template.light.custom",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/light.custom.css",
"attributes": {"media": "all"}
},
{
"name": "template.light.custom.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/light.custom.min.css",
"attributes": {"media": "all"}
},
{
"name": "template.dark.standard",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/dark.standard.css",
"attributes": {"media": "all"}
},
{
"name": "template.dark.standard.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/dark.standard.min.css",
"attributes": {"media": "all"}
},
{
"name": "template.dark.custom",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/dark.custom.css",
"attributes": {"media": "all"}
},
{
"name": "template.dark.custom.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/dark.custom.min.css",
"attributes": {"media": "all"}
},
{
"name": "template.a11y-high-contrast",
"type": "style",
@@ -148,80 +100,38 @@
"uri": "media/templates/site/mokoonyx/js/template.js",
"attributes": {"defer": true}
},
{
"name": "template.js.min",
"type": "script",
"uri": "media/templates/site/mokoonyx/js/template.min.js",
"attributes": {"defer": true}
},
{
"name": "gtm.js",
"type": "script",
"uri": "media/templates/site/mokoonyx/js/gtm.js",
"attributes": {"defer": true}
},
{
"name": "gtm.min.js",
"type": "script",
"uri": "media/templates/site/mokoonyx/js/gtm.min.js",
"attributes": {"defer": true}
},
{
"name": "vendor.fa7free.all",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/all.css"
},
{
"name": "vendor.fa7free.all.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/all.min.css",
"attributes": {"media": "all"}
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/all.min.css"
},
{
"name": "vendor.fa7free.brands",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/brands.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.brands.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/brands.min.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.fontawesome",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/fontawesome.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.fontawesome.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/fontawesome.min.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.regular",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/regular.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.regular.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/regular.min.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.solid",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/solid.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.solid.min",
"type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/solid.min.css",
"attributes": {"media": "all"}
},
-13
View File
@@ -11,12 +11,6 @@ TPL_MOKOONYX_FLUID_LABEL="Layout"
TPL_MOKOONYX_STATIC="Static"
TPL_MOKOONYX_FLUID="Fluid"
; ===== Custom Code tab =====
TPL_MOKOONYX_CUSTOM_CODE_FIELDSET="Custom Code"
TPL_MOKOONYX_CUSTOM_HEAD_START_LABEL="Custom Head: Start"
TPL_MOKOONYX_CUSTOM_HEAD_START_DESC="This content will be inserted at the beginning of the &lt;head&gt; tag"
TPL_MOKOONYX_CUSTOM_HEAD_END_LABEL="Custom Head: End"
TPL_MOKOONYX_CUSTOM_HEAD_END_DESC="This content will be inserted at the end of the &lt;head&gt; tag"
TPL_MOKOONYX_OFFLINEEMBED_LABEL="Offline Page Embed Code"
TPL_MOKOONYX_OFFLINEEMBED_DESC="In addition to the 'Offline message' defined in 'Global Configuration', this will be displayed on the offline page.<i>Use for Mailchimp code and Social Icons</i>"
@@ -269,13 +263,6 @@ TPL_MOKOONYX_THEME_PREVIEW_FIELDSET_LABEL="Theme Preview"
TPL_MOKOONYX_THEME_PREVIEW_INTRO="<p>Live preview of all CSS variables, hero variants, block colours, and Bootstrap components rendered with your active theme. Use the <strong>Toggle Light / Dark</strong> button inside the preview to switch modes. This page is also available as a standalone file at <code>templates/mokoonyx/templates/theme-test.html</code>.</p>"
TPL_MOKOONYX_THEME_PREVIEW_FRAME="<iframe src='../templates/mokoonyx/templates/theme-test.html' style='width:100%;height:80vh;border:1px solid #dee2e6;border-radius:.375rem;' loading='lazy' title='Theme test sheet preview'></iframe>"
; ===== Migration =====
TPL_MOKOONYX_MIGRATION_FIELDSET_LABEL="Migration"
TPL_MOKOONYX_MIGRATION_NOTE_LABEL="MokoCassiopeia Migration"
TPL_MOKOONYX_MIGRATION_NOTE_DESC="MokoOnyx automatically imports settings from MokoCassiopeia on first page load. If you need to re-run the migration, delete the file <code>templates/mokoonyx/.migrated</code> and visit any frontend page. Check <code>administrator/logs/mokoonyx_migrate.log.php</code> to confirm."
TPL_MOKOONYX_MIGRATION_RUN_LABEL="Re-run Migration"
TPL_MOKOONYX_MIGRATION_RUN_DESC="<strong>To re-run the migration:</strong> Delete <code>templates/mokoonyx/.migrated</code> via FTP or file manager, then visit any page on your site. The migration will run again automatically.<br><br><strong>To uninstall MokoCassiopeia:</strong> Go to <a href='index.php?option=com_installer&amp;view=manage&amp;filter[search]=mokocassiopeia'>Extensions → Manage</a>, find MokoCassiopeia, and click Uninstall."
; ===== Misc =====
MOD_BREADCRUMBS_HERE="YOU ARE HERE:"
-13
View File
@@ -11,12 +11,6 @@ TPL_MOKOONYX_FLUID_LABEL="Layout"
TPL_MOKOONYX_STATIC="Static"
TPL_MOKOONYX_FLUID="Fluid"
; ===== Custom Code tab =====
TPL_MOKOONYX_CUSTOM_CODE_FIELDSET="Custom Code"
TPL_MOKOONYX_CUSTOM_HEAD_START_LABEL="Custom Head: Start"
TPL_MOKOONYX_CUSTOM_HEAD_START_DESC="This content will be inserted at the beginning of the &lt;head&gt; tag"
TPL_MOKOONYX_CUSTOM_HEAD_END_LABEL="Custom Head: End"
TPL_MOKOONYX_CUSTOM_HEAD_END_DESC="This content will be inserted at the end of the &lt;head&gt; tag"
TPL_MOKOONYX_OFFLINEEMBED_LABEL="Offline Page Embed Code"
TPL_MOKOONYX_OFFLINEEMBED_DESC="In addition to the 'Offline message' defined in 'Global Configuration', this will be displayed on the offline page.<i>Use for Mailchimp code and Social Icons</i>"
@@ -269,13 +263,6 @@ TPL_MOKOONYX_THEME_PREVIEW_FIELDSET_LABEL="Theme Preview"
TPL_MOKOONYX_THEME_PREVIEW_INTRO="<p>Live preview of all CSS variables, hero variants, block colors, and Bootstrap components rendered with your active theme. Use the <strong>Toggle Light / Dark</strong> button inside the preview to switch modes. This page is also available as a standalone file at <code>templates/mokoonyx/templates/theme-test.html</code>.</p>"
TPL_MOKOONYX_THEME_PREVIEW_FRAME="<iframe src='../templates/mokoonyx/templates/theme-test.html' style='width:100%;height:80vh;border:1px solid #dee2e6;border-radius:.375rem;' loading='lazy' title='Theme test sheet preview'></iframe>"
; ===== Migration =====
TPL_MOKOONYX_MIGRATION_FIELDSET_LABEL="Migration"
TPL_MOKOONYX_MIGRATION_NOTE_LABEL="MokoCassiopeia Migration"
TPL_MOKOONYX_MIGRATION_NOTE_DESC="MokoOnyx automatically imports settings from MokoCassiopeia on first page load. If you need to re-run the migration, delete the file <code>templates/mokoonyx/.migrated</code> and visit any frontend page. Check <code>administrator/logs/mokoonyx_migrate.log.php</code> to confirm."
TPL_MOKOONYX_MIGRATION_RUN_LABEL="Re-run Migration"
TPL_MOKOONYX_MIGRATION_RUN_DESC="<strong>To re-run the migration:</strong> Delete <code>templates/mokoonyx/.migrated</code> via FTP or file manager, then visit any page on your site. The migration will run again automatically.<br><br><strong>To uninstall MokoCassiopeia:</strong> Go to <a href='index.php?option=com_installer&amp;view=manage&amp;filter[search]=mokocassiopeia'>Extensions → Manage</a>, find MokoCassiopeia, and click Uninstall."
; ===== Misc =====
MOD_BREADCRUMBS_HERE="YOU ARE HERE:"
+225
View File
@@ -0,0 +1,225 @@
@charset "UTF-8";
/* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
*/
/* ==========================================================================
Component / Print-view stylesheet
Used by component.php (tmpl=component) for modals, popups, and print.
Consumes theme variables from light.standard.css (always light in this view).
========================================================================== */
/* ---- Screen: minimal chrome for modal / popup views ---- */
body {
margin: 0;
padding: 1.5rem;
font-family: var(--body-font-family);
font-size: var(--body-font-size, 1rem);
font-weight: var(--body-font-weight, 400);
line-height: var(--body-line-height, 1.5);
color: var(--body-color, #22262a);
background: var(--body-bg, #fff);
}
.navbar-brand {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: var(--border-width, 1px) var(--border-style, solid) var(--border-color, #dfe3e7);
}
.navbar-brand .brand-logo {
text-decoration: none;
color: var(--color-primary, #112855);
}
.navbar-brand .logo {
max-height: 48px;
width: auto;
}
.navbar-brand .site-title {
font-size: var(--navbar-brand-font-size, 1.25rem);
font-weight: 600;
color: var(--color-primary, #112855);
}
a {
color: var(--link-color, #224faa);
text-decoration: var(--link-decoration, underline);
}
a:hover {
color: var(--link-hover-color, #424077);
}
h1, h2, h3, h4, h5, h6 {
color: var(--heading-color, inherit);
margin-top: 0;
margin-bottom: 0.5rem;
}
img {
max-width: 100%;
height: auto;
}
table {
border-collapse: collapse;
width: 100%;
color: var(--table-color, var(--body-color));
background-color: var(--table-bg, transparent);
}
th, td {
padding: 0.5rem;
border: var(--border-width, 1px) var(--border-style, solid) var(--table-border-color, var(--border-color, #dfe3e7));
text-align: left;
vertical-align: top;
}
th {
font-weight: 600;
color: var(--emphasis-color, #000);
}
.container-footer {
margin-top: 2rem;
padding-top: 0.75rem;
border-top: var(--border-width, 1px) var(--border-style, solid) var(--border-color, #dfe3e7);
font-size: 0.85rem;
color: var(--muted-color, #6d757e);
}
pre, code {
font-family: var(--font-monospace);
color: var(--code-color-ink, #e93f8e);
}
pre {
background: var(--tertiary-bg, #f9fafb);
padding: 1rem;
border-radius: var(--border-radius, .25rem);
overflow-x: auto;
}
blockquote {
padding: 0.5rem 1rem;
margin: 0 0 1rem;
border-left: 4px solid var(--accent-color-primary, #3f8ff0);
color: var(--secondary-color, #22262abf);
}
hr {
border: 0;
border-top: var(--border-width, 1px) solid var(--hr-color, #dfe3e7);
margin: 1rem 0;
}
/* ---- Print ---- */
@media print {
@page {
margin: 1.5cm;
size: auto;
}
*,
*::before,
*::after {
color: #000 !important;
background: transparent !important;
box-shadow: none !important;
text-shadow: none !important;
}
body {
padding: 0;
font-size: 11pt;
line-height: 1.4;
}
.navbar-brand {
border-bottom: 1px solid #000;
margin-bottom: 0.75cm;
padding-bottom: 0.25cm;
}
.navbar-brand .logo {
max-height: 36px;
}
.container-footer,
.alert,
.btn,
iframe,
video,
audio,
[data-module],
#debug,
.joomla-script-options {
display: none !important;
}
a[href] {
text-decoration: underline !important;
}
a[href]::after {
content: " (" attr(href) ")";
font-size: 0.8em;
font-weight: normal;
}
a[href^="#"]::after,
a[href^="javascript:"]::after,
a[href^="mailto:"]::after {
content: "";
}
h1, h2, h3, h4 {
page-break-after: avoid;
orphans: 3;
widows: 3;
}
img, table, figure, pre, blockquote {
page-break-inside: avoid;
}
thead {
display: table-header-group;
}
tr {
page-break-inside: avoid;
}
p, h2, h3 {
orphans: 3;
widows: 3;
}
table {
border-color: #000 !important;
}
th, td {
border-color: #000 !important;
}
pre {
border: 1px solid #999;
white-space: pre-wrap;
word-wrap: break-word;
}
blockquote {
border-left-color: #000 !important;
}
}
+1 -1
View File
@@ -16386,7 +16386,7 @@ body:not(.has-sidebar-right) .site-grid .container-component {
.nav-tabs+.tab-content {
padding: 0.9375rem;
background: var(--body-color, #e6ebf1);
background: var(--body-bg, #e6ebf1);
border: 1px solid;
border-color: hsl(210, 14%, 89%);
border-radius: 0 0 0.25rem 0.25rem;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-31
View File
@@ -1,31 +0,0 @@
/*!
* Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2025 Fonticons, Inc.
*/
:root, :host {
--fa-family-classic: "Font Awesome 7 Free";
--fa-font-regular: normal 400 1em/1 var(--fa-family-classic);
/* deprecated: this older custom property will be removed next major release */
--fa-style-family-classic: var(--fa-family-classic);
}
@font-face {
font-family: "Font Awesome 7 Free";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("../webfonts/fa-regular-400.woff2");
}
.far {
--fa-family: var(--fa-family-classic);
--fa-style: 400;
}
.fa-classic {
--fa-family: var(--fa-family-classic);
}
.fa-regular {
--fa-style: 400;
}
-31
View File
@@ -1,31 +0,0 @@
/*!
* Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2025 Fonticons, Inc.
*/
:root, :host {
--fa-family-classic: "Font Awesome 7 Free";
--fa-font-solid: normal 900 1em/1 var(--fa-family-classic);
/* deprecated: this older custom property will be removed next major release */
--fa-style-family-classic: var(--fa-family-classic);
}
@font-face {
font-family: "Font Awesome 7 Free";
font-style: normal;
font-weight: 900;
font-display: block;
src: url("../webfonts/fa-solid-900.woff2");
}
.fas {
--fa-family: var(--fa-family-classic);
--fa-style: 900;
}
.fa-classic {
--fa-family: var(--fa-family-classic);
}
.fa-solid {
--fa-style: 900;
}
+82 -1
View File
@@ -88,11 +88,11 @@ class Tpl_MokoonyxInstallerScript implements InstallerScriptInterface
public function postflight(string $type, InstallerAdapter $parent): bool
{
// On install or update: migrate from MokoCassiopeia if it exists
if ($type === 'install' || $type === 'update') {
$this->migrateFromCassiopeia();
$this->replaceCassiopeiaReferences();
$this->clearFaviconStamp();
$this->cleanMediaFolder();
$this->lockExtension();
}
@@ -402,6 +402,87 @@ class Tpl_MokoonyxInstallerScript implements InstallerScriptInterface
}
}
/**
* Clean the template media folder on install/update.
*
* - Removes stale .min files (regenerated automatically by MokoMinifyHelper)
* - Removes deprecated/renamed files from previous versions
* - Removes unminified vendor files (vendors ship .min only)
*/
private function cleanMediaFolder(): void
{
$mediaRoot = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME;
if (!is_dir($mediaRoot)) {
return;
}
$removed = 0;
// 1. Delete all .min.css and .min.js in project dirs (MokoMinifyHelper rebuilds them)
// Skip vendor/ — those are pre-minified originals
$projectDirs = ['css', 'js'];
foreach ($projectDirs as $dir) {
$path = $mediaRoot . '/' . $dir;
if (!is_dir($path)) continue;
$this->deleteMinFilesRecursive($path, $removed);
}
// 2. Remove unminified vendor files (vendors ship .min only)
$vendorUnminified = [
'vendor/fa7free/css/all.css',
'vendor/fa7free/css/brands.css',
'vendor/fa7free/css/fontawesome.css',
'vendor/fa7free/css/regular.css',
'vendor/fa7free/css/solid.css',
];
foreach ($vendorUnminified as $relPath) {
$file = $mediaRoot . '/' . $relPath;
if (is_file($file)) {
@unlink($file);
$removed++;
}
}
// 3. Remove deprecated files from previous versions
$deprecated = [
'css/custom.css', // Renamed to css/user.css
'js/custom.js', // Renamed to js/user.js
'css/template-rtl.css', // No longer used
];
foreach ($deprecated as $relPath) {
$file = $mediaRoot . '/' . $relPath;
if (is_file($file)) {
@unlink($file);
$removed++;
}
}
if ($removed > 0) {
$this->logMessage("Cleaned media folder: removed {$removed} stale/deprecated file(s).");
}
}
/**
* Recursively delete *.min.css and *.min.js in a directory.
*/
private function deleteMinFilesRecursive(string $dir, int &$count): void
{
$entries = scandir($dir);
if (!$entries) return;
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') continue;
$full = $dir . '/' . $entry;
if (is_dir($full)) {
$this->deleteMinFilesRecursive($full, $count);
} elseif (preg_match('/\.min\.(css|js)$/', $entry)) {
@unlink($full);
$count++;
}
}
}
private function logMessage(string $message, string $priority = 'info'): void
{
$priorities = [
+4 -14
View File
@@ -39,13 +39,13 @@
</server>
</updateservers>
<name>MokoOnyx</name>
<version>02.01.00</version>
<version>02.01.06</version>
<scriptfile>script.php</scriptfile>
<creationDate>2026-05-05</creationDate>
<creationDate>2026-05-09</creationDate>
<author>Jonathan Miller || Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<copyright>(C)GNU General Public License Version 3 - 2026 Moko Consulting</copyright>
<description><![CDATA[<div style="padding:1rem;border:2px solid #059669;border-radius:8px;background:#ecfdf5;margin-bottom:1rem;"> <h3 style="color:#065f46;margin:0 0 .5rem;">Migrating from MokoCassiopeia?</h3> <p style="margin:0 0 .75rem;">MokoOnyx automatically imports your MokoCassiopeia settings on first use. To trigger the migration:</p> <ol style="margin:0 0 .75rem 1.5rem;"> <li>Install MokoOnyx via <strong>System → Install → Extensions</strong></li> <li>Go to <strong>System → Site Templates</strong> and set <strong>MokoOnyx</strong> as your default template</li> <li><strong>Visit any page on your site</strong> — the migration runs automatically on first page load</li> <li>Check <strong>administrator/logs/mokoonyx_migrate.log.php</strong> to confirm migration completed</li> <li>Uninstall MokoCassiopeia from <strong>Extensions → Manage</strong></li> </ol> <p style="margin:0;font-size:.9em;color:#065f46;"> <strong>What gets migrated:</strong> template style params, custom colour palettes (light.custom.css, dark.custom.css), user.css, user.js, and update server URL. To re-run the migration, delete <code>templates/mokoonyx/.migrated</code> and reload any page. </p></div> <p><img src="https://img.shields.io/gitea/v/release/MokoConsulting/MokoOnyx?gitea_url=https%3A%2F%2Fgit.mokoconsulting.tech&amp;logo=gitea&amp;logoColor=white&amp;label=version" alt="Version" /> <img src="https://img.shields.io/badge/license-GPL--3.0--or--later-green.svg?logo=gnu&amp;logoColor=white" alt="License" /> <img src="https://img.shields.io/badge/Joomla-5.x%20%7C%206.x-red.svg?logo=joomla&amp;logoColor=white" alt="Joomla" /> <img src="https://img.shields.io/badge/PHP-8.1%2B-777BB4.svg?logo=php&amp;logoColor=white" alt="PHP" /></p> <h3>MokoOnyx</h3> <p> <strong>MokoOnyx</strong> (formerly MokoCassiopeia) is a modern, lightweight enhancement layer built on Joomla's Cassiopeia template. It adds Font Awesome 7, Bootstrap 5 helpers, automatic Table of Contents, advanced Dark Mode theming, and optional Google Tag Manager / GA4 integration — all with minimal core overrides for maximum upgrade compatibility. </p> <h4>Custom Colour Themes</h4> <p> Copy <code>templates/mokoonyx/templates/light.custom.css</code> to <code>media/templates/site/mokoonyx/css/theme/light.custom.css</code> (or dark equivalent), customise the CSS variables, then select "Custom" in the Theme tab. </p> <h4>Custom CSS &amp; JavaScript</h4> <ul> <li><code>media/templates/site/mokoonyx/css/user.css</code> — custom CSS overrides</li> <li><code>media/templates/site/mokoonyx/js/user.js</code> — custom JavaScript</li> </ul> <p>These files survive template updates.</p>]]></description>
<description><![CDATA[<p><img src="https://img.shields.io/gitea/v/release/MokoConsulting/MokoOnyx?gitea_url=https%3A%2F%2Fgit.mokoconsulting.tech&amp;logo=gitea&amp;logoColor=white&amp;label=version" alt="Version" /> <img src="https://img.shields.io/badge/license-GPL--3.0--or--later-green.svg?logo=gnu&amp;logoColor=white" alt="License" /> <img src="https://img.shields.io/badge/Joomla-5.x%20%7C%206.x-red.svg?logo=joomla&amp;logoColor=white" alt="Joomla" /> <img src="https://img.shields.io/badge/PHP-8.1%2B-777BB4.svg?logo=php&amp;logoColor=white" alt="PHP" /></p> <h3>MokoOnyx</h3> <p> <strong>MokoOnyx</strong> is a modern, lightweight enhancement layer built on Joomla's Cassiopeia template. It adds Font Awesome 7, Bootstrap 5 helpers, automatic Table of Contents, advanced Dark Mode theming, and optional Google Tag Manager / GA4 integration — all with minimal core overrides for maximum upgrade compatibility. </p> <h4>Custom Colour Themes</h4> <p> Copy <code>templates/mokoonyx/templates/light.custom.css</code> to <code>media/templates/site/mokoonyx/css/theme/light.custom.css</code> (or dark equivalent), customise the CSS variables, then select "Custom" in the Theme tab. </p> <h4>Custom CSS &amp; JavaScript</h4> <ul> <li><code>media/templates/site/mokoonyx/css/user.css</code> — custom CSS overrides</li> <li><code>media/templates/site/mokoonyx/js/user.js</code> — custom JavaScript</li> </ul> <p>These files survive template updates.</p>]]></description>
<inheritable>1</inheritable>
<files>
<filename>component.php</filename>
@@ -107,11 +107,7 @@
<config>
<fields name="params">
<!-- Advanced tab (non-theme/system options only) -->
<fieldset name="migration" label="TPL_MOKOONYX_MIGRATION_FIELDSET_LABEL">
<field name="migration_note" type="note" label="TPL_MOKOONYX_MIGRATION_NOTE_LABEL" description="TPL_MOKOONYX_MIGRATION_NOTE_DESC" />
<field name="migration_run" type="note" label="TPL_MOKOONYX_MIGRATION_RUN_LABEL" description="TPL_MOKOONYX_MIGRATION_RUN_DESC" />
</fieldset>
<!-- Advanced tab -->
<fieldset name="advanced">
<field name="developmentmode" type="radio" label="TPL_MOKOONYX_DEVELOPMENTMODE_LABEL" description="TPL_MOKOONYX_DEVELOPMENTMODE_DESC" default="0" layout="joomla.form.field.radio.switcher" filter="boolean">
<option value="0">JNO</option>
@@ -148,12 +144,6 @@
</field>
</fieldset>
<!-- Custom Code tab -->
<fieldset name="custom_head" label="TPL_MOKOONYX_CUSTOM_CODE_FIELDSET">
<field name="custom_head_start" type="textarea" default="" label="TPL_MOKOONYX_CUSTOM_HEAD_START_LABEL" description="TPL_MOKOONYX_CUSTOM_HEAD_START_DESC" filter="raw" />
<field name="custom_head_end" type="textarea" default="" label="TPL_MOKOONYX_CUSTOM_HEAD_END_LABEL" description="TPL_MOKOONYX_CUSTOM_HEAD_END_DESC" filter="raw" />
</fieldset>
<!-- Drawers tab -->
<fieldset name="drawers" label="TPL_MOKOONYX_DRAWERS_FIELDSET_LABEL">
<field name="drawerLeftIcon" type="text" default="fa-solid fa-chevron-right" label="TPL_MOKOONYX_DRAWER_LEFT_ICON_LABEL" description="TPL_MOKOONYX_DRAWER_LEFT_ICON_DESC" filter="string" />
+8 -7
View File
@@ -7,20 +7,21 @@
<updates>
<update>
<name>MokoOnyx</name>
<description>MokoOnyx update</description>
<description>MokoOnyx development build.</description>
<element>mokoonyx</element>
<type>template</type>
<version>02.01.00</version>
<client>site</client>
<tags><tag>development</tag></tags>
<infourl title="MokoOnyx">https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/stable</infourl>
<version>02.01.08</version>
<creationDate>2026-05-11</creationDate>
<infourl title='MokoOnyx'>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/stable/-02.01.00.zip</downloadurl>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.08-dev.zip</downloadurl>
</downloads>
<sha256>e86c851b2b6e49ce46abd76235a1193fe98a0d0c82133356dfaaa28328ceb2b9</sha256>
<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />
<sha256>698e87f9cc67884461287f56dc2611c794caf3a1ab6787503045988a2b829a49</sha256>
<tags><tag>development</tag></tags>
<maintainer>Moko Consulting</maintainer>
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
<targetplatform name='joomla' version='(5|6).*'/>
</update>
<update>
<name>MokoOnyx</name>