diff --git a/.gitea/.mokostandards b/.gitea/.mokostandards
deleted file mode 100644
index 272b5a4..0000000
--- a/.gitea/.mokostandards
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
- MokoOnyx
- MokoConsulting
- MokoOnyx - Joomla site template (successor to MokoCassiopeia)
- GNU General Public License v3
-
-
- joomla
- 04.07.00
- https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
- 2026-05-02T23:06:05+00:00
-
-
- CSS
- php:>=8.1
- joomla-extension
- src/templateDetails.xml
-
-
-
-
-
-
-
-
-
diff --git a/.gitignore b/.gitignore
index d89e74c..fde323a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.mcp.json b/.mcp.json
deleted file mode 100644
index 9c3149a..0000000
--- a/.mcp.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "mcpServers": {
- "joomla-api": {
- "type": "stdio",
- "command": "node",
- "args": ["A:/joomla-api-mcp/dist/index.js"]
- }
- }
-}
diff --git a/.mokogitea/ISSUE_TEMPLATE/adr.md b/.mokogitea/ISSUE_TEMPLATE/adr.md
new file mode 100644
index 0000000..eb40760
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/adr.md
@@ -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
diff --git a/.mokogitea/ISSUE_TEMPLATE/bug_report.md b/.mokogitea/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..38a16a7
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/bug_report.md
@@ -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
diff --git a/.mokogitea/ISSUE_TEMPLATE/config.yml b/.mokogitea/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..d4d49ec
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/config.yml
@@ -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
diff --git a/.mokogitea/ISSUE_TEMPLATE/documentation.md b/.mokogitea/ISSUE_TEMPLATE/documentation.md
new file mode 100644
index 0000000..ed4dabc
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/documentation.md
@@ -0,0 +1,52 @@
+---
+name: Documentation Issue
+about: Report an issue with documentation
+title: '[DOCS] '
+labels: 'documentation'
+assignees: ''
+
+---
+
+
+## Documentation Issue
+
+**Location**:
+
+
+## Issue Type
+
+- [ ] Typo or grammar error
+- [ ] Outdated information
+- [ ] Missing documentation
+- [ ] Unclear explanation
+- [ ] Broken links
+- [ ] Missing examples
+- [ ] Other (specify below)
+
+## Description
+
+
+## Current Content
+
+```
+Current text here
+```
+
+## Suggested Improvement
+
+```
+Suggested text here
+```
+
+## Additional Context
+
+
+## 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)
diff --git a/.mokogitea/ISSUE_TEMPLATE/feature_request.md b/.mokogitea/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..7b76dc9
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/feature_request.md
@@ -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
diff --git a/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md b/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md
new file mode 100644
index 0000000..d808f79
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/joomla_issue.md
@@ -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
diff --git a/.mokogitea/ISSUE_TEMPLATE/question.md b/.mokogitea/ISSUE_TEMPLATE/question.md
new file mode 100644
index 0000000..3175013
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/question.md
@@ -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)
diff --git a/.mokogitea/ISSUE_TEMPLATE/rfc.md b/.mokogitea/ISSUE_TEMPLATE/rfc.md
new file mode 100644
index 0000000..6f09af7
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/rfc.md
@@ -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.
diff --git a/.mokogitea/ISSUE_TEMPLATE/security.md b/.mokogitea/ISSUE_TEMPLATE/security.md
new file mode 100644
index 0000000..f57b284
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/security.md
@@ -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**:
+
+
+## Description
+
+
+## Affected Components
+
+
+## Suggested Mitigation
+
+
+## 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
+
+
+## Checklist
+- [ ] This is NOT a critical vulnerability requiring private disclosure
+- [ ] I have reviewed the SECURITY.md policy
+- [ ] I have provided sufficient detail for evaluation
diff --git a/.mokogitea/ISSUE_TEMPLATE/version.md b/.mokogitea/ISSUE_TEMPLATE/version.md
new file mode 100644
index 0000000..6328421
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/version.md
@@ -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**:
+**Requested version**:
+**Change type**:
+
+## Reason
+
+
+
+## Checklist
+
+- [ ] README.md `VERSION:` field updated
+- [ ] CHANGELOG.md entry added
+- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: ``)
+- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow
diff --git a/.gitea/workflows/auto-release.yml b/.mokogitea/auto-release.yml
similarity index 80%
rename from .gitea/workflows/auto-release.yml
rename to .mokogitea/auto-release.yml
index 0bc7775..279bc5e 100644
--- a/.gitea/workflows/auto-release.yml
+++ b/.mokogitea/auto-release.yml
@@ -151,13 +151,22 @@ jobs:
sed -i "s|[^<]*|${TODAY}|" "$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/.*\(\).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/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 site
CLIENT_TAG=""
@@ -374,7 +408,18 @@ jobs:
PHP_TAG="${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 '/dev/null | head -1)
- EXT_ELEMENT=""
- if [ -n "$MANIFEST" ]; then
- EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/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 '/dev/null | head -1 || true)
[ -z "$MANIFEST" ] && exit 0
- EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/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>.*/\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/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_FOLDER=$(sed -n 's/.*]*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()
diff --git a/.gitea/workflows/cascade-dev.yml b/.mokogitea/cascade-dev.yml
similarity index 100%
rename from .gitea/workflows/cascade-dev.yml
rename to .mokogitea/cascade-dev.yml
diff --git a/.gitea/workflows/ci-joomla.yml b/.mokogitea/ci-joomla.yml
similarity index 84%
rename from .gitea/workflows/ci-joomla.yml
rename to .mokogitea/ci-joomla.yml
index 17284d1..28cee48 100644
--- a/.gitea/workflows/ci-joomla.yml
+++ b/.mokogitea/ci-joomla.yml
@@ -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
diff --git a/.gitea/workflows/cleanup.yml b/.mokogitea/cleanup.yml
similarity index 100%
rename from .gitea/workflows/cleanup.yml
rename to .mokogitea/cleanup.yml
diff --git a/.gitea/workflows/deploy-manual.yml b/.mokogitea/deploy-manual.yml
similarity index 100%
rename from .gitea/workflows/deploy-manual.yml
rename to .mokogitea/deploy-manual.yml
diff --git a/.mokogitea/dispatch-css-sync.yml b/.mokogitea/dispatch-css-sync.yml
new file mode 100644
index 0000000..fe233e7
--- /dev/null
+++ b/.mokogitea/dispatch-css-sync.yml
@@ -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
diff --git a/.mokogitea/gitleaks.yml b/.mokogitea/gitleaks.yml
new file mode 100644
index 0000000..b29f881
--- /dev/null
+++ b/.mokogitea/gitleaks.yml
@@ -0,0 +1,96 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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
diff --git a/.gitea/workflows/notify.yml b/.mokogitea/notify.yml
similarity index 100%
rename from .gitea/workflows/notify.yml
rename to .mokogitea/notify.yml
diff --git a/.mokogitea/pr-branch-check.yml b/.mokogitea/pr-branch-check.yml
new file mode 100644
index 0000000..b8d9742
--- /dev/null
+++ b/.mokogitea/pr-branch-check.yml
@@ -0,0 +1,90 @@
+# Copyright (C) 2026 Moko Consulting
+# 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
diff --git a/.gitea/workflows/pr-check.yml b/.mokogitea/pr-check.yml
similarity index 100%
rename from .gitea/workflows/pr-check.yml
rename to .mokogitea/pr-check.yml
diff --git a/.gitea/workflows/pre-release.yml b/.mokogitea/pre-release.yml
similarity index 92%
rename from .gitea/workflows/pre-release.yml
rename to .mokogitea/pre-release.yml
index 4969383..30c9bcf 100644
--- a/.gitea/workflows/pre-release.yml
+++ b/.mokogitea/pre-release.yml
@@ -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: |
diff --git a/.gitea/workflows/repo-health.yml b/.mokogitea/repo-health.yml
similarity index 100%
rename from .gitea/workflows/repo-health.yml
rename to .mokogitea/repo-health.yml
diff --git a/.gitea/workflows/security-audit.yml b/.mokogitea/security-audit.yml
similarity index 100%
rename from .gitea/workflows/security-audit.yml
rename to .mokogitea/security-audit.yml
diff --git a/.gitea/workflows/update-server.yml b/.mokogitea/update-server.yml
similarity index 100%
rename from .gitea/workflows/update-server.yml
rename to .mokogitea/update-server.yml
diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml
new file mode 100644
index 0000000..84fc701
--- /dev/null
+++ b/.mokogitea/workflows/auto-release.yml
@@ -0,0 +1,1007 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Release
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/universal/auto-release.yml.template
+# VERSION: 05.00.00
+# BRIEF: Universal build & release � detects platform from manifest.xml
+#
+# +========================================================================+
+# | UNIVERSAL BUILD & RELEASE PIPELINE |
+# +========================================================================+
+# | |
+# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
+# | |
+# | Platform-specific: |
+# | joomla: XML manifest, updates.xml, type-prefixed packages |
+# | dolibarr: mod*.class.php, update.txt, dev version reset |
+# | generic: README-only, no update stream |
+# | |
+# +========================================================================+
+
+name: "Universal: Build & Release"
+
+on:
+ pull_request:
+ types: [closed]
+ branches:
+ - main
+ paths:
+ - 'src/**'
+ - 'htdocs/**'
+ workflow_dispatch:
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+ GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
+ GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ name: Build & Release Pipeline
+ runs-on: release
+ if: >-
+ github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ token: ${{ secrets.GA_TOKEN }}
+ fetch-depth: 0
+
+ - name: Setup MokoStandards tools
+ env:
+ MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
+ MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
+ run: |
+ # Ensure PHP + Composer are available
+ if ! command -v composer &> /dev/null; then
+ sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
+ fi
+ git clone --depth 1 --branch main --quiet \
+ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
+ /tmp/mokostandards-api
+ cd /tmp/mokostandards-api
+ composer install --no-dev --no-interaction --quiet
+
+
+ # -- PLATFORM DETECTION ---------------------------------------------------
+ - name: Detect platform
+ id: platform
+ run: |
+ # Read platform from XML manifest ( tag) or plain text fallback
+ PLATFORM=$(sed -n 's/.*\([^<]*\)<\/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"
+ echo "Platform detected: ${PLATFORM}"
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/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"
+
+ # -- STEP 1: Read version -----------------------------------------------
+ - name: "Step 1: Read version from README.md"
+ id: version
+ run: |
+ VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null)
+ if [ -z "$VERSION" ]; then
+ echo "No VERSION in README.md — skipping release"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ # Derive major.minor for branch naming (patches update existing branch)
+ MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}')
+ PATCH=$(echo "$VERSION" | awk -F. '{print $3}')
+
+ MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
+ MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}')
+
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
+ echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
+ echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
+ echo "release_tag=stable" >> "$GITHUB_OUTPUT"
+ echo "stability=stable" >> "$GITHUB_OUTPUT"
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ if [ "$PATCH" = "00" ] || [ "$PATCH" = "01" ]; then
+ echo "is_minor=true" >> "$GITHUB_OUTPUT"
+ echo "Version: $VERSION (first release for this minor — full pipeline)"
+ else
+ echo "is_minor=false" >> "$GITHUB_OUTPUT"
+ echo "Version: $VERSION (patch — platform version + badges only)"
+ fi
+
+ # -- STEP 1b: Bump minor version (stable = minor bump, reset patch) ------
+ - name: "Step 1b: Bump minor version for stable release"
+ if: steps.version.outputs.skip != 'true'
+ id: bump
+ run: |
+ CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
+ [ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
+
+ MAJOR=$((10#$(echo "$CURRENT" | cut -d. -f1)))
+ MINOR=$((10#$(echo "$CURRENT" | cut -d. -f2)))
+
+ # Minor bump, reset patch. Rollover if minor > 99
+ MINOR=$((MINOR + 1))
+ if [ $MINOR -gt 99 ]; then
+ MINOR=0
+ MAJOR=$((MAJOR + 1))
+ fi
+
+ VERSION=$(printf "%02d.%02d.00" $MAJOR $MINOR)
+ TODAY=$(date +%Y-%m-%d)
+
+ echo "Stable bump: ${CURRENT} → ${VERSION} (minor)"
+
+ # Update README.md
+ sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
+
+ # Update 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>.*/\1/p' "$MANIFEST" | head -1)
+ [ -n "$MANIFEST_VER" ] && sed -i "s|${MANIFEST_VER}|${VERSION}|" "$MANIFEST"
+ sed -i "s|[^<]*|${TODAY}|" "$MANIFEST"
+ fi
+ ;;
+ dolibarr)
+ if [ -n "$MOD_FILE" ]; then
+ sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
+ fi
+ echo "${VERSION}" > update.txt
+ ;;
+ *) ;;
+ esac
+
+ # Promote [Unreleased] section in CHANGELOG.md to new version
+ if [ -f "CHANGELOG.md" ] && grep -qi "Unreleased" CHANGELOG.md; then
+ sed -i "s|## \[Unreleased\]|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md
+ sed -i "s|## Unreleased|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md
+ sed -i "2i ## [Unreleased]" CHANGELOG.md
+ sed -i "3i \\ " CHANGELOG.md
+ echo "CHANGELOG promoted to [${VERSION}]"
+ fi
+
+ # Commit and push
+ git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "gitea-actions[bot]"
+ git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+ git add -A
+ git diff --cached --quiet || {
+ git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
+ git push origin HEAD:main 2>&1
+ }
+
+ # Override version output for rest of pipeline
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "major=$(printf "%02d" $MAJOR)" >> "$GITHUB_OUTPUT"
+
+ - name: Check if already released
+ if: steps.version.outputs.skip != 'true'
+ id: check
+ run: |
+ TAG="${{ steps.version.outputs.release_tag }}"
+ BRANCH="${{ steps.version.outputs.branch }}"
+
+ TAG_EXISTS=false
+ BRANCH_EXISTS=false
+
+ git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
+ git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
+
+ echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
+ echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
+
+ # Tag and branch may persist across patch releases — never skip
+ echo "already_released=false" >> "$GITHUB_OUTPUT"
+
+ # -- SANITY CHECKS -------------------------------------------------------
+ - name: "Sanity: Pre-release validation"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ ERRORS=0
+
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ MANIFEST="${{ steps.platform.outputs.manifest }}"
+ MOD_FILE="${{ steps.platform.outputs.mod_file }}"
+ echo "## Pre-Release Sanity Checks (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # -- Version drift check (must pass before release) --------
+ README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
+ if [ "$README_VER" != "$VERSION" ]; then
+ echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Check CHANGELOG version matches
+ CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
+ if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
+ echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi
+
+ # Check composer.json version if present
+ if [ -f "composer.json" ]; then
+ COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
+ if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
+ echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi
+ fi
+
+ # Common checks
+ if [ ! -f "LICENSE" ]; then
+ echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
+ echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- Platform-specific checks --------
+ case "$PLATFORM" in
+ joomla)
+ if [ -n "$MANIFEST" ]; then
+ XML_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
+ echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+ TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
+ echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "- No Joomla XML manifest (WaaS site)" >> $GITHUB_STEP_SUMMARY
+ fi ;;
+ dolibarr)
+ if [ -n "$MOD_FILE" ]; then
+ MOD_VER=$(sed -n "s/.*\\\$this->version = '\([^']*\)'.*/\1/p" "$MOD_FILE" 2>/dev/null | head -1)
+ if [ -n "$MOD_VER" ] && [ "$MOD_VER" != "$VERSION" ]; then
+ echo "- Module drift: \`${MOD_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- Module version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+ else
+ echo "- No mod*.class.php found" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi
+ if [ ! -f "update.txt" ]; then
+ echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi ;;
+ *) echo "- Generic platform � no manifest checks" >> $GITHUB_STEP_SUMMARY ;;
+ esac
+
+ echo "" >> $GITHUB_STEP_SUMMARY
+ if [ "$ERRORS" -gt 0 ]; then
+ echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- STEP 2: Create or update version/XX.YY archive branch ---------------
+ # Always runs — every version change on main archives to version/XX.YY
+ - name: "Step 2: Version archive branch"
+ if: steps.check.outputs.already_released != 'true'
+ run: |
+ BRANCH="${{ steps.version.outputs.branch }}"
+ IS_MINOR="${{ steps.version.outputs.is_minor }}"
+ PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
+
+ # Check if branch exists
+ if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
+ git push origin HEAD:"$BRANCH" --force
+ echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
+ else
+ git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
+ git push origin "$BRANCH" --force
+ echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- STEP 3: Set platform version ----------------------------------------
+ - name: "Step 3: Set platform version"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ php /tmp/mokostandards-api/cli/version_set_platform.php \
+ --path . --version "$VERSION" --branch main
+
+ # -- STEP 4: Update version badges ----------------------------------------
+ - name: "Step 4: Update version badges"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do
+ if grep -q '\[VERSION:' "$f" 2>/dev/null; then
+ sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f"
+ fi
+ done
+
+ # -- STEP 5: Write updates.xml (Joomla update server) ---------------------
+ - name: "Step 5: Write update stream"
+ id: updates
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ REPO="${{ github.repository }}"
+
+ # -- Parse extension metadata from XML manifest ----------------
+ MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1)
+ if [ -z "$MANIFEST" ]; then
+ echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY
+ exit 0
+ fi
+
+ # Extract fields using sed (portable — no grep -P)
+ EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
+ EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
+ EXT_CLIENT=$(sed -n 's/.*]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ TARGET_PLATFORM=$(sed -n 's/.*\(\).*/\1/p' "$MANIFEST" | head -1)
+ PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
+
+ # If EXT_NAME is a language key (e.g. PLG_SYSTEM_MOKOJGDPC), resolve from .ini
+ if echo "$EXT_NAME" | grep -qE '^[A-Z_]+$'; then
+ INI_NAME=$(find . -name "*.sys.ini" -path "*/en-GB/*" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
+ [ -z "$INI_NAME" ] && INI_NAME=$(find . -name "*.sys.ini" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
+ [ -n "$INI_NAME" ] && EXT_NAME="$INI_NAME"
+ fi
+
+ # Fallbacks
+ [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
+ [ -z "$EXT_TYPE" ] && EXT_TYPE="component"
+
+ # Derive element if not in manifest:
+ # 1. plugin="xxx" attribute (plugins)
+ # 2. module="xxx" attribute (modules)
+ # 3. XML filename (components, packages)
+ # 4. Repo name fallback (templates, anything else)
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ fi
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*module="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ fi
+ if [ -z "$EXT_ELEMENT" ]; then
+ FNAME=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ # If filename is generic (templateDetails, manifest), use repo name
+ case "$FNAME" in
+ templatedetails|manifest) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
+ *) EXT_ELEMENT="$FNAME" ;;
+ esac
+ fi
+ # Final fallback
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+
+ # Save for Steps 7, 8, 8b
+ echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
+ echo "ext_name=${EXT_NAME}" >> "$GITHUB_OUTPUT"
+ echo "ext_type=${EXT_TYPE}" >> "$GITHUB_OUTPUT"
+ echo "ext_folder=${EXT_FOLDER}" >> "$GITHUB_OUTPUT"
+
+ # Build client tag: plugins and frontend modules need site
+ CLIENT_TAG=""
+ if [ -n "$EXT_CLIENT" ]; then
+ CLIENT_TAG="${EXT_CLIENT}"
+ elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
+ CLIENT_TAG="site"
+ fi
+
+ # Build folder tag for plugins (required for Joomla to match the update)
+ FOLDER_TAG=""
+ if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
+ FOLDER_TAG="${EXT_FOLDER}"
+ fi
+
+ # Build targetplatform (fallback to Joomla 5 if not in manifest)
+ if [ -z "$TARGET_PLATFORM" ]; then
+ TARGET_PLATFORM=$(printf '' "/")
+ fi
+
+ # Build php_minimum tag
+ PHP_TAG=""
+ if [ -n "$PHP_MINIMUM" ]; then
+ PHP_TAG="${PHP_MINIMUM}"
+ fi
+
+ # Build TYPE_PREFIX for download URL
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+
+ DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable"
+
+ # -- Build update entry for a given stability tag
+ build_entry() {
+ local TAG_NAME="$1"
+ printf '%s\n' ' '
+ printf '%s\n' " ${EXT_NAME}"
+ printf '%s\n' " ${EXT_NAME} update"
+ printf '%s\n' " ${EXT_ELEMENT}"
+ printf '%s\n' " ${EXT_TYPE}"
+ printf '%s\n' " ${VERSION}"
+ [ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
+ [ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
+ printf '%s\n' " ${TAG_NAME}"
+ printf '%s\n' " ${INFO_URL}"
+ printf '%s\n' ' '
+ printf '%s\n' " ${DOWNLOAD_URL}"
+ printf '%s\n' ' '
+ printf '%s\n' " ${TARGET_PLATFORM}"
+ [ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}"
+ printf '%s\n' ' Moko Consulting'
+ printf '%s\n' ' https://mokoconsulting.tech'
+ printf '%s\n' ' '
+ }
+
+ # -- Write updates.xml with cascading channels
+ # Stable release updates ALL channels (development, alpha, beta, rc, stable)
+ {
+ printf '%s\n' ""
+ printf '%s\n' ""
+ printf '%s\n' ""
+ printf '%s\n' ''
+ build_entry "development"
+ build_entry "alpha"
+ build_entry "beta"
+ build_entry "rc"
+ build_entry "stable"
+ printf '%s\n' ''
+ } > updates.xml
+
+ echo "updates.xml: ${VERSION} (all channels updated to stable)" >> $GITHUB_STEP_SUMMARY
+
+ # -- Commit all changes ---------------------------------------------------
+ - name: Commit release changes
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ if git diff --quiet && git diff --cached --quiet; then
+ echo "No changes to commit"
+ exit 0
+ fi
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "gitea-actions[bot]"
+ # Set push URL with token for branch-protected repos
+ git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+ git add -A
+ git commit -m "chore(release): build ${VERSION} [skip ci]" \
+ --author="gitea-actions[bot] "
+ git push -u origin HEAD
+
+ # -- STEP 6: Create tag ---------------------------------------------------
+ - name: "Step 6: Create git tag"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.tag_exists != 'true' &&
+ steps.version.outputs.is_minor == 'true'
+ run: |
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ # Only create the major release tag if it doesn't exist yet
+ if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
+ git tag "$RELEASE_TAG"
+ git push origin "$RELEASE_TAG"
+ echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
+ fi
+ echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 7: Create or update Gitea Release --------------------------------
+ - name: "Step 7: Gitea Release"
+ if: >-
+ steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ BRANCH="${{ steps.version.outputs.branch }}"
+ MAJOR="${{ steps.version.outputs.major }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+
+ # Reuse metadata from Step 5 (single source of truth)
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ EXT_NAME="${{ steps.updates.outputs.ext_name }}"
+ EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
+ EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
+
+ # Fallbacks if Step 5 was skipped
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ fi
+ [ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
+
+ NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
+ [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
+
+ # Build release name: "Pretty Name VERSION (type_element-VERSION)"
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
+
+ # Delete existing release if present (overwrite, not append)
+ EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
+ EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$EXISTING_ID" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true
+ echo "Deleted previous stable release (id: ${EXISTING_ID})"
+ fi
+
+ # Create fresh release
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/releases" \
+ -d "$(python3 -c "import json; print(json.dumps({
+ 'tag_name': '${RELEASE_TAG}',
+ 'name': '${RELEASE_NAME}',
+ 'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''',
+ 'target_commitish': '${BRANCH}'
+ }))")"
+ echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
+ - name: "Step 8: Build package and update checksum"
+ if: >-
+ steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ REPO="${{ github.repository }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+
+ # All ZIPs upload to the major release tag (vXX)
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
+ RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+ if [ -z "$RELEASE_ID" ]; then
+ echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
+ exit 0
+ fi
+
+ # Find extension element name from manifest
+ MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true)
+ [ -z "$MANIFEST" ] && exit 0
+
+ # Reuse element from Step 5, with same fallback chain
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\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/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
+
+ # -- Build install packages from src/ ----------------------------
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — skipping package"; exit 0; }
+
+ EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
+
+ # ZIP package
+ cd "$SOURCE_DIR"
+ zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES
+ cd ..
+
+ # tar.gz package
+ tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
+ --exclude='.ftpignore' --exclude='sftp-config*' \
+ --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
+
+ ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
+ TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
+
+ # -- Calculate SHA-256 for both ----------------------------------
+ SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
+ SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
+
+ # -- Delete existing assets with same name before uploading ------
+ ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
+ for ASSET_NAME in "$ZIP_NAME" "$TAR_NAME"; do
+ ASSET_ID=$(echo "$ASSETS" | python3 -c "
+ import sys,json
+ assets = json.load(sys.stdin)
+ for a in assets:
+ if a['name'] == '${ASSET_NAME}':
+ print(a['id']); break
+ " 2>/dev/null || true)
+ if [ -n "$ASSET_ID" ]; then
+ curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
+ fi
+ done
+
+ # -- Upload both to release tag ----------------------------------
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary @"/tmp/${ZIP_NAME}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" > /dev/null 2>&1 || true
+
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary @"/tmp/${TAR_NAME}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
+
+ # -- Update updates.xml with both download formats ---------------
+ if [ -f "updates.xml" ]; then
+ ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}"
+ TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}"
+
+ # Use Python to update only the stable entry's downloads + sha256
+ export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP"
+ python3 << 'PYEOF'
+ import re, os
+
+ with open("updates.xml") as f:
+ content = f.read()
+
+ zip_url = os.environ["PY_ZIP_URL"]
+ tar_url = os.environ["PY_TAR_URL"]
+ sha = os.environ["PY_SHA"]
+
+ # Find the stable update block and replace its downloads + sha256
+ def replace_stable(m):
+ block = m.group(0)
+ # Replace downloads block
+ new_downloads = (
+ " \n"
+ f" {zip_url}\n"
+ " "
+ )
+ block = re.sub(r' .*?', new_downloads, block, flags=re.DOTALL)
+ # Add or replace sha256
+ if '' in block:
+ block = re.sub(r' .*?', f' {sha}', block)
+ else:
+ block = block.replace('', f'\n {sha}')
+ return block
+
+ content = re.sub(
+ r' .*?stable.*?',
+ replace_stable,
+ content,
+ flags=re.DOTALL
+ )
+
+ with open("updates.xml", "w") as f:
+ f.write(content)
+ PYEOF
+
+ CURRENT_BRANCH="${{ github.ref_name }}"
+ git add updates.xml
+ git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
+ --author="gitea-actions[bot] " || true
+ git push || true
+
+ # Sync updates.xml to main via direct API (always runs — may be on version/XX branch)
+ GA_TOKEN="${{ secrets.GA_TOKEN }}"
+ API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
+
+ FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
+
+ if [ -n "$FILE_SHA" ]; then
+ CONTENT=$(base64 -w0 updates.xml)
+ curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API}/contents/updates.xml" \
+ -d "$(jq -n \
+ --arg content "$CONTENT" \
+ --arg sha "$FILE_SHA" \
+ --arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
+ --arg branch "main" \
+ '{content: $content, sha: $sha, message: $msg, branch: $branch}'
+ )" > /dev/null 2>&1 \
+ && echo "updates.xml synced to main via API" \
+ || echo "WARNING: failed to sync updates.xml to main"
+ else
+ echo "WARNING: could not get updates.xml SHA from main"
+ fi
+ fi
+
+ echo "### Packages" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY
+ echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY
+ echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
+ echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 8b: Update release description with changelog + SHA ----------------
+ - name: "Step 8b: Update release body with changelog and SHA"
+ if: steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
+ EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
+
+ # Build TYPE_PREFIX to match Step 8's ZIP naming
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
+
+ # Get SHA from the built files
+ SHA256_ZIP=""
+ [ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
+ SHA256_TAR=""
+ [ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
+
+ # Extract latest changelog entry (strip the ## header to avoid duplicate)
+ CHANGELOG=""
+ if [ -f "CHANGELOG.md" ]; then
+ CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
+ [ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
+ fi
+
+ # Build release body (single header, no duplicate from changelog)
+ BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
+ if [ -n "$CHANGELOG" ]; then
+ BODY="${BODY}${CHANGELOG}\n\n"
+ fi
+ BODY="${BODY}---\n\n### Checksums\n\n"
+ BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
+ [ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
+ [ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
+
+ # Get release ID and update body
+ RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
+ python3 -c "
+ import json, urllib.request
+ body = '''$(printf '%b' "$BODY")'''
+ data = json.dumps({'body': body}).encode()
+ req = urllib.request.Request(
+ '${API_BASE}/releases/${RELEASE_ID}',
+ data=data,
+ headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
+ method='PATCH'
+ )
+ urllib.request.urlopen(req)
+ " 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
+ - name: "Step 9: Mirror release to GitHub"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.version.outputs.stability == 'stable' &&
+ secrets.GH_TOKEN != ''
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ MAJOR="${{ steps.version.outputs.major }}"
+ BRANCH="${{ steps.version.outputs.branch }}"
+ GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
+
+ NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
+ [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
+ echo "$NOTES" > /tmp/release_notes.md
+
+ EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
+
+ if [ -z "$EXISTING" ]; then
+ gh release create "$RELEASE_TAG" \
+ --repo "$GH_REPO" \
+ --title "v${MAJOR} (latest: ${VERSION})" \
+ --notes-file /tmp/release_notes.md \
+ --target "$BRANCH" || true
+ else
+ gh release edit "$RELEASE_TAG" \
+ --repo "$GH_REPO" \
+ --title "v${MAJOR} (latest: ${VERSION})" || true
+ fi
+
+ # Upload assets to GitHub mirror
+ for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
+ if [ -f "$PKG" ]; then
+ _RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
+ [ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
+ fi
+ done
+ echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 10: Sync main branch to GitHub mirror ----------------------------
+ - name: "Step 10: Push main to GitHub mirror"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ secrets.GH_TOKEN != ''
+ continue-on-error: true
+ run: |
+ GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
+ GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
+ GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
+ git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
+ git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
+ git fetch origin main --depth=1
+ git push github origin/main:refs/heads/main --force 2>/dev/null \
+ && echo "main branch pushed to GitHub mirror" \
+ || echo "WARNING: GitHub mirror push failed"
+
+ # -- Clean up lesser pre-releases (cascade) ---------------------------------
+ # stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev
+ - name: "Delete lesser pre-release channels"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+
+ # Stable deletes all pre-release channels
+ TAGS_TO_DELETE="development alpha beta release-candidate"
+
+ DELETED=0
+ for TAG in $TAGS_TO_DELETE; do
+ RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/tags/${TAG}" 2>/dev/null || true
+ echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
+ DELETED=$((DELETED + 1))
+ fi
+ done
+ echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 11: Reset dev branch from main ------------------------------------
+ - name: "Step 11: Delete and recreate dev branch from main"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+
+ # Delete dev branch
+ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
+
+ # Recreate dev from main (now includes version bump + changelog promotion)
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/branches" \
+ -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
+
+ echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
+
+
+ # -- Dolibarr post-release: Reset dev version -----------------------------
+ - name: "Dolibarr: Reset dev version"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.platform.outputs.platform == 'dolibarr' &&
+ steps.platform.outputs.mod_file != ''
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+ MOD_FILE="${{ steps.platform.outputs.mod_file }}"
+ ENCODED_PATH=$(echo "$MOD_FILE" | sed 's|^\./||' | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))")
+ FILE_RESP=$(curl -sf -H "Authorization: token ${TOKEN}" "${API_BASE}/contents/${ENCODED_PATH}?ref=dev" 2>/dev/null || true)
+ FILE_SHA=$(echo "$FILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
+ FILE_CONTENT=$(echo "$FILE_RESP" | python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin).get('content','')).decode())" 2>/dev/null || true)
+ if [ -n "$FILE_SHA" ] && [ -n "$FILE_CONTENT" ]; then
+ UPDATED=$(echo "$FILE_CONTENT" | sed "s/\$this->version = '[^']*'/\$this->version = 'development'/")
+ ENCODED=$(echo "$UPDATED" | base64 -w0)
+ curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/contents/${ENCODED_PATH}" \
+ -d "$(jq -n --arg content \"$ENCODED\" --arg sha \"$FILE_SHA\" --arg msg \"chore(version): reset dev version [skip ci]\" --arg branch \"dev\" '{content:$content,sha:$sha,message:$msg,branch:$branch}')" > /dev/null 2>&1 || true
+ fi
+
+ # -- Summary --------------------------------------------------------------
+ - name: Pipeline Summary
+ if: always()
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
+ echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
+ echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
+ elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
+ echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
+ echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
+ fi
diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml
new file mode 100644
index 0000000..4dbb135
--- /dev/null
+++ b/.mokogitea/workflows/cascade-dev.yml
@@ -0,0 +1,213 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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
diff --git a/.mokogitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml
new file mode 100644
index 0000000..5c66f14
--- /dev/null
+++ b/.mokogitea/workflows/ci-joomla.yml
@@ -0,0 +1,450 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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 /dev/null; then
+ MANIFEST="$XML_FILE"
+ break
+ fi
+ done
+
+ if [ -z "$MANIFEST" ]; then
+ echo "No Joomla extension manifest found (XML file with \`> $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 "/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 "/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 matches README VERSION
+ MANIFEST_VERSION=$(grep -oP '\K[^<]+' "$MANIFEST" | head -1)
+ if [ -z "$MANIFEST_VERSION" ]; then
+ echo "No \`\` 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 ']*\btype="\K[^"]+' "$MANIFEST" | head -1)
+ if [ -z "$EXT_TYPE" ]; then
+ echo "Missing \`type\` attribute on \`\` 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 \`\` or \`\` 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 ']*\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
diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml
new file mode 100644
index 0000000..3a81856
--- /dev/null
+++ b/.mokogitea/workflows/cleanup.yml
@@ -0,0 +1,87 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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)"
diff --git a/.mokogitea/workflows/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml
new file mode 100644
index 0000000..bb133ed
--- /dev/null
+++ b/.mokogitea/workflows/deploy-manual.yml
@@ -0,0 +1,126 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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
diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml
new file mode 100644
index 0000000..0c07612
--- /dev/null
+++ b/.mokogitea/workflows/gitleaks.yml
@@ -0,0 +1,96 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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
diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml
new file mode 100644
index 0000000..463a900
--- /dev/null
+++ b/.mokogitea/workflows/notify.yml
@@ -0,0 +1,71 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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}"
diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml
new file mode 100644
index 0000000..99e063f
--- /dev/null
+++ b/.mokogitea/workflows/pr-check.yml
@@ -0,0 +1,196 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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 ( tag) or plain text fallback
+ PLATFORM=$(sed -n 's/.*\([^<]*\)<\/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 '/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; }
diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml
new file mode 100644
index 0000000..c70ea7d
--- /dev/null
+++ b/.mokogitea/workflows/pre-release.yml
@@ -0,0 +1,384 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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 ( tag) or plain text fallback
+ PLATFORM=$(sed -n 's/.*\([^<]*\)<\/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 '/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>.*/\1/p' "$MANIFEST" | head -1)
+ sed -i "s|${MANIFEST_VER}|${VERSION}|" "$MANIFEST"
+ sed -i "s|[^<]*|${TODAY}|" "$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>.*/\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"((?:(?!).)*?" + re.escape(xml_tag) + r".*?)"
+ match = re.search(pattern, content, re.DOTALL)
+ if match:
+ block = match.group(1)
+ updated = re.sub(r"[^<]*", f"{version}", block)
+ updated = re.sub(r"[^<]*", f"{date}", updated)
+ if "" in updated:
+ updated = re.sub(r"[^<]*", f"{sha256}", updated)
+ else:
+ updated = updated.replace("", f"\n {sha256}")
+ updated = re.sub(r"(]*>)[^<]*()", 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 {xml_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
diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml
new file mode 100644
index 0000000..e5e1c73
--- /dev/null
+++ b/.mokogitea/workflows/repo-health.yml
@@ -0,0 +1,766 @@
+# ============================================================================
+# Copyright (C) 2025 Moko Consulting
+#
+# 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/)")
+ 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 '/dev/null | head -1 || true)"
+ if [ -z "${MANIFEST}" ]; then
+ joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)")
+ else
+ if ! grep -qP '' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: 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 '' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: tag missing")
+ fi
+ if ! grep -qP '' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: tag missing")
+ fi
+ if ! grep -qP ' 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}"
diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml
new file mode 100644
index 0000000..789325a
--- /dev/null
+++ b/.mokogitea/workflows/security-audit.yml
@@ -0,0 +1,82 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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
diff --git a/.mokogitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml
new file mode 100644
index 0000000..6e617f6
--- /dev/null
+++ b/.mokogitea/workflows/update-server.yml
@@ -0,0 +1,464 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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 entries:
+# - stable on push to main (from auto-release)
+# - rc on push to rc/**
+# - development 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] " 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 '/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>.*/\1/p' "$MANIFEST" | head -1)
+ EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
+ EXT_CLIENT=$(sed -n 's/.*]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_VERSION=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
+ TARGET_PLATFORM=$(sed -n 's/.*\(\).*/\1/p' "$MANIFEST" | head -1)
+ PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/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 '' "/")
+
+ CLIENT_TAG=""
+ [ -n "$EXT_CLIENT" ] && CLIENT_TAG="${EXT_CLIENT}"
+ [ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="site"
+
+ FOLDER_TAG=""
+ [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="${EXT_FOLDER}"
+
+ PHP_TAG=""
+ [ -n "$PHP_MINIMUM" ] && PHP_TAG="${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} \n"
+ NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME}\n"
+ NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME} ${STABILITY} build.\n"
+ NEW_ENTRY="${NEW_ENTRY} ${EXT_ELEMENT}\n"
+ NEW_ENTRY="${NEW_ENTRY} ${EXT_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}\n"
+ NEW_ENTRY="${NEW_ENTRY} $(date +%Y-%m-%d)\n"
+ NEW_ENTRY="${NEW_ENTRY} https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}\n"
+ NEW_ENTRY="${NEW_ENTRY} \n"
+ NEW_ENTRY="${NEW_ENTRY} ${DOWNLOAD_URL}\n"
+ NEW_ENTRY="${NEW_ENTRY} \n"
+ [ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} ${SHA256}\n"
+ NEW_ENTRY="${NEW_ENTRY} ${STABILITY}\n"
+ NEW_ENTRY="${NEW_ENTRY} Moko Consulting\n"
+ NEW_ENTRY="${NEW_ENTRY} https://mokoconsulting.tech\n"
+ NEW_ENTRY="${NEW_ENTRY} \n"
+ [ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} ${PHP_MINIMUM}\n"
+ NEW_ENTRY="${NEW_ENTRY} "
+
+ # -- 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' "" > updates.xml
+ printf '%s\n' "" >> updates.xml
+ printf '%s\n' "" >> updates.xml
+ printf '%s\n' "" >> 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"[^<]*", f"{tag}", new_entry_template)
+
+ # Try to find existing block (handles both single-line and multi-line )
+ block_pattern = r"((?:(?!).)*?" + re.escape(tag) + r".*?)"
+ 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} → {version}")
+ else:
+ # Create — insert before
+ content = content.replace("", "\n" + new_entry.strip() + "\n\n")
+ print(f" CREATED: {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] "
+ 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2de5655..bbee9d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Makefile b/Makefile
index 2263186..16e381c 100644
--- a/Makefile
+++ b/Makefile
@@ -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/
diff --git a/README.md b/README.md
index 017d977..7a25634 100644
--- a/README.md
+++ b/README.md
@@ -1,54 +1,118 @@
-
-
# 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**
+ 
-[](https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/stable)
-[](LICENSE)
-[](https://www.joomla.org)
-[](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)*
diff --git a/src/component.php b/src/component.php
index fb329e4..84fbcd8 100644
--- a/src/component.php
+++ b/src/component.php
@@ -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);
}
})('');
+ gtag('event', 'page_view', {
+ 'page_title': document.title,
+ 'content_group': 'print_view',
+ 'custom_map': {'dimension1': 'template_view'},
+ 'template_view': 'component'
+ });
diff --git a/src/error.php b/src/error.php
index 34dcc3a..9a797c0 100644
--- a/src/error.php
+++ b/src/error.php
@@ -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
-
-
diff --git a/src/html/com_content/article/toc-right.php b/src/html/com_content/article/toc-right.php
index a2bc9c6..1c6652d 100644
--- a/src/html/com_content/article/toc-right.php
+++ b/src/html/com_content/article/toc-right.php
@@ -90,6 +90,10 @@ $assocParam = (Associations::isEnabled() && $params->get('show_associations'));
item->event->afterDisplayContent; ?>
+ item->jcfields)) : ?>
+ $this->item]); ?>
+
+
get('show_tags', 1) && !empty($this->item->tags->itemTags)) : ?>
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;
+ }
+}
diff --git a/src/html/layouts/mokoonyx/article-metadata.php b/src/html/layouts/mokoonyx/article-metadata.php
new file mode 100644
index 0000000..418f164
--- /dev/null
+++ b/src/html/layouts/mokoonyx/article-metadata.php
@@ -0,0 +1,76 @@
+
+ *
+ * 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;
+}
+?>
+
diff --git a/src/html/layouts/mokoonyx/index.html b/src/html/layouts/mokoonyx/index.html
new file mode 100644
index 0000000..2efb97f
--- /dev/null
+++ b/src/html/layouts/mokoonyx/index.html
@@ -0,0 +1 @@
+
diff --git a/src/index.php b/src/index.php
index 00a1653..aea6227 100644
--- a/src/index.php
+++ b/src/index.php
@@ -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
-
@@ -327,7 +318,6 @@ $wa->useScript('user.js'); // js/user.js
-
templates/mokoonyx/.migrated and visit any frontend page. Check administrator/logs/mokoonyx_migrate.log.php to confirm."
-TPL_MOKOONYX_MIGRATION_RUN_LABEL="Re-run Migration"
-TPL_MOKOONYX_MIGRATION_RUN_DESC="To re-run the migration: Delete templates/mokoonyx/.migrated via FTP or file manager, then visit any page on your site. The migration will run again automatically.
To uninstall MokoCassiopeia: Go to Extensions → Manage, find MokoCassiopeia, and click Uninstall."
-
; ===== Misc =====
MOD_BREADCRUMBS_HERE="YOU ARE HERE:"
diff --git a/src/language/en-US/tpl_mokoonyx.ini b/src/language/en-US/tpl_mokoonyx.ini
index f83f78d..cbc4777 100644
--- a/src/language/en-US/tpl_mokoonyx.ini
+++ b/src/language/en-US/tpl_mokoonyx.ini
@@ -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 <head> 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 <head> 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.Use for Mailchimp code and Social Icons"
@@ -269,13 +263,6 @@ TPL_MOKOONYX_THEME_PREVIEW_FIELDSET_LABEL="Theme Preview"
TPL_MOKOONYX_THEME_PREVIEW_INTRO="
Live preview of all CSS variables, hero variants, block colors, and Bootstrap components rendered with your active theme. Use the Toggle Light / Dark button inside the preview to switch modes. This page is also available as a standalone file at templates/mokoonyx/templates/theme-test.html.
"
TPL_MOKOONYX_THEME_PREVIEW_FRAME=""
-; ===== 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 templates/mokoonyx/.migrated and visit any frontend page. Check administrator/logs/mokoonyx_migrate.log.php to confirm."
-TPL_MOKOONYX_MIGRATION_RUN_LABEL="Re-run Migration"
-TPL_MOKOONYX_MIGRATION_RUN_DESC="To re-run the migration: Delete templates/mokoonyx/.migrated via FTP or file manager, then visit any page on your site. The migration will run again automatically.
MokoOnyx automatically imports your MokoCassiopeia settings on first use. To trigger the migration:
Install MokoOnyx via System → Install → Extensions
Go to System → Site Templates and set MokoOnyx as your default template
Visit any page on your site — the migration runs automatically on first page load
Check administrator/logs/mokoonyx_migrate.log.php to confirm migration completed
Uninstall MokoCassiopeia from Extensions → Manage
What gets migrated: 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 templates/mokoonyx/.migrated and reload any page.
MokoOnyx
MokoOnyx (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.
Custom Colour Themes
Copy templates/mokoonyx/templates/light.custom.css to media/templates/site/mokoonyx/css/theme/light.custom.css (or dark equivalent), customise the CSS variables, then select "Custom" in the Theme tab.
MokoOnyx 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.
Custom Colour Themes
Copy templates/mokoonyx/templates/light.custom.css to media/templates/site/mokoonyx/css/theme/light.custom.css (or dark equivalent), customise the CSS variables, then select "Custom" in the Theme tab.