diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..09975f7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,31 @@ +# EditorConfig https://editorconfig.org +root = true + +[*] +indent_style = tab +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.ps1] +end_of_line = crlf + +[*.md] +trim_trailing_whitespace = false + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.{mak,Makefile}] +indent_style = tab + +[*.bat] +end_of_line = crlf + +[*.sh] +end_of_line = lf +indent_style = space +indent_size = 2 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1321885 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "packages/MokoSuiteClient"] + path = packages/MokoSuiteClient + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git +[submodule "packages/MokoSuiteCRM"] + path = packages/MokoSuiteCRM + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git 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/.mokogitea/branch-protection.yml b/.mokogitea/branch-protection.yml new file mode 100644 index 0000000..2dff8b9 --- /dev/null +++ b/.mokogitea/branch-protection.yml @@ -0,0 +1,251 @@ +# Copyright (C) 2026 Moko Consulting +# SPDX-License-Identifier: GPL-3.0-or-later +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Automation +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.gitea/workflows/branch-protection.yml +# BRIEF: Apply standardised branch protection rules to all governed repositories +# +# +========================================================================+ +# | BRANCH PROTECTION SETUP | +# +========================================================================+ +# | | +# | Applies protection rules for: main, dev, rc, beta, alpha | +# | | +# | main β€” Require PR, block rejected reviews, no force push | +# | dev β€” Allow push, no force push, no delete | +# | rc β€” Allow push, no force push, no delete | +# | beta β€” Allow push, no force push, no delete | +# | alpha β€” Allow push, no force push, no delete | +# | | +# | jmiller has override authority on all branches. | +# | | +# +========================================================================+ + +name: Branch Protection Setup + +on: + schedule: + - cron: '0 2 * * 1' # Weekly Monday 02:00 UTC + workflow_dispatch: + inputs: + dry_run: + description: 'Preview mode (no changes)' + required: false + type: boolean + default: false + repos: + description: 'Comma-separated repo names (empty = all governed repos)' + required: false + type: string + default: '' + +env: + GITEA_URL: https://git.mokoconsulting.tech + GITEA_ORG: MokoConsulting + +permissions: + contents: read + +jobs: + protect: + name: Apply Branch Protection Rules + runs-on: ubuntu-latest + + steps: + - name: Determine target repos + id: repos + env: + GA_TOKEN: ${{ secrets.GA_TOKEN }} + run: | + API="${GITEA_URL}/api/v1" + + # Platform/standards/infra repos to exclude + EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting" + EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" + + if [ -n "${{ inputs.repos }}" ]; then + # User-specified repos + REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ') + else + # Fetch all org repos + PAGE=1 + REPOS="" + while true; do + BATCH=$(curl -sS \ + -H "Authorization: token ${GA_TOKEN}" \ + "${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \ + | jq -r '.[].name // empty') + [ -z "$BATCH" ] && break + REPOS="$REPOS $BATCH" + PAGE=$((PAGE + 1)) + done + + # Filter out excluded repos + FILTERED="" + for REPO in $REPOS; do + SKIP=false + for EX in $EXCLUDE; do + if [ "$REPO" = "$EX" ]; then + SKIP=true + break + fi + done + if [ "$SKIP" = "false" ]; then + FILTERED="$FILTERED $REPO" + fi + done + REPOS="$FILTERED" + fi + + echo "repos=$REPOS" >> "$GITHUB_OUTPUT" + COUNT=$(echo "$REPOS" | wc -w) + echo "πŸ“‹ Target repos (${COUNT}): $REPOS" + + - name: Apply protection rules + env: + GA_TOKEN: ${{ secrets.GA_TOKEN }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + run: | + API="${GITEA_URL}/api/v1" + REPOS="${{ steps.repos.outputs.repos }}" + + SUCCESS=0 + FAILED=0 + SKIPPED=0 + + # ── Rule definitions ────────────────────────────────────── + # Only the CI bot (jmiller token) can push directly. + # All human contributors must use PRs. + # Force push disabled on all branches. + + RULE_MAIN='{ + "rule_name": "main", + "enable_push": true, + "enable_push_whitelist": true, + "push_whitelist_usernames": ["jmiller"], + "enable_force_push": false, + "enable_force_push_allowlist": false, + "force_push_allowlist_usernames": [], + "enable_merge_whitelist": false, + "required_approvals": 0, + "dismiss_stale_approvals": true, + "block_on_rejected_reviews": true, + "block_on_outdated_branch": false, + "priority": 1 + }' + + RULE_DEV='{ + "rule_name": "dev", + "enable_push": true, + "enable_push_whitelist": true, + "push_whitelist_usernames": ["jmiller"], + "enable_force_push": false, + "enable_force_push_allowlist": false, + "force_push_allowlist_usernames": [], + "enable_merge_whitelist": false, + "required_approvals": 0, + "block_on_rejected_reviews": false, + "priority": 2 + }' + + RULE_RC='{ + "rule_name": "rc", + "enable_push": true, + "enable_push_whitelist": true, + "push_whitelist_usernames": ["jmiller"], + "enable_force_push": false, + "enable_force_push_allowlist": false, + "force_push_allowlist_usernames": [], + "enable_merge_whitelist": false, + "required_approvals": 0, + "block_on_rejected_reviews": false, + "priority": 3 + }' + + RULE_BETA='{ + "rule_name": "beta", + "enable_push": true, + "enable_push_whitelist": true, + "push_whitelist_usernames": ["jmiller"], + "enable_force_push": false, + "enable_force_push_allowlist": false, + "force_push_allowlist_usernames": [], + "enable_merge_whitelist": false, + "required_approvals": 0, + "block_on_rejected_reviews": false, + "priority": 4 + }' + + RULE_ALPHA='{ + "rule_name": "alpha", + "enable_push": true, + "enable_push_whitelist": true, + "push_whitelist_usernames": ["jmiller"], + "enable_force_push": false, + "enable_force_push_allowlist": false, + "force_push_allowlist_usernames": [], + "enable_merge_whitelist": false, + "required_approvals": 0, + "block_on_rejected_reviews": false, + "priority": 5 + }' + + RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA") + RULE_NAMES=("main" "dev" "rc" "beta" "alpha") + + # ── Apply rules to each repo ────────────────────────────── + for REPO in $REPOS; do + echo "" + echo "═══ ${REPO} ═══" + + for i in "${!RULES[@]}"; do + RULE="${RULES[$i]}" + NAME="${RULE_NAMES[$i]}" + + if [ "$DRY_RUN" = "true" ]; then + echo " [DRY RUN] Would apply rule: ${NAME}" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + # Delete existing rule if present (idempotent recreate) + ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g') + curl -sS -o /dev/null -w "" \ + -X DELETE \ + -H "Authorization: token ${GA_TOKEN}" \ + "${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true + + # Create rule + RESPONSE=$(curl -sS -w "\n%{http_code}" \ + -X POST \ + -H "Authorization: token ${GA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$RULE" \ + "${API}/repos/${GITEA_ORG}/${REPO}/branch_protections") + + HTTP=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP" = "201" ]; then + echo " βœ… ${NAME}" + SUCCESS=$((SUCCESS + 1)) + else + echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)" + FAILED=$((FAILED + 1)) + fi + done + done + + # ── Summary ─────────────────────────────────────────────── + echo "" + echo "════════════════════════════════════════" + echo " βœ… Success: ${SUCCESS}" + echo " ❌ Failed: ${FAILED}" + echo " ⏭️ Skipped: ${SKIPPED}" + echo "════════════════════════════════════════" + + if [ "$FAILED" -gt 0 ]; then + echo "::warning::${FAILED} rule(s) failed to apply" + fi diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml new file mode 100644 index 0000000..6c13103 --- /dev/null +++ b/.mokogitea/workflows/auto-bump.yml @@ -0,0 +1,66 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /.mokogitea/workflows/auto-bump.yml +# VERSION: 09.02.00 +# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) + +name: "Universal: Auto Version Bump" + +on: + push: + branches: + - dev + - rc + - 'feature/**' + - 'patch/**' + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + +permissions: + contents: write + +jobs: + bump: + name: Version Bump + runs-on: release + if: >- + !contains(github.event.head_commit.message, '[skip ci]') && + !contains(github.event.head_commit.message, '[skip bump]') && + !startsWith(github.event.head_commit.message, 'Merge pull request') + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 1 + + - name: Setup mokocli tools + run: | + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + if [ -d "/opt/mokocli/cli" ]; then + echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV" + else + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \ + /tmp/mokocli + cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet + echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV" + fi + + - name: Bump version + run: | + php ${MOKO_CLI}/version_auto_bump.php \ + --path . --branch "${GITHUB_REF_NAME}" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml new file mode 100644 index 0000000..3be5d44 --- /dev/null +++ b/.mokogitea/workflows/auto-release.yml @@ -0,0 +1,457 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Release +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli +# PATH: /templates/workflows/universal/auto-release.yml.template +# VERSION: 05.00.00 +# BRIEF: Universal build & release οΏ½ detects platform from manifest.xml +# +# +=======================================================================+ +# | UNIVERSAL BUILD & RELEASE PIPELINE | +# +=======================================================================+ +# | | +# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | +# | | +# | Platform-specific: | +# | joomla: XML manifest, type-prefixed packages | +# | dolibarr: mod*.class.php, update.txt, dev version reset | +# | generic: README-only, no update stream | +# | | +# +=======================================================================+ + +name: "Universal: Build & Release" + +on: + pull_request: + types: [opened, closed] + branches: + - main + workflow_dispatch: + inputs: + action: + description: 'Action to perform' + required: false + type: choice + default: release + options: + - release + - promote-rc + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +permissions: + contents: write + +jobs: + # ── PR Opened β†’ Rename branch to RC and build RC release ───────────────────────── + promote-rc: + name: Promote to RC + runs-on: release + if: >- + (github.event.action == 'opened' && github.event.pull_request.merged != true) || + (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 1 + + - name: Setup mokocli tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then + echo Using pre-installed /opt/mokocli + echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV + else + echo Falling back to fresh clone + if ! command -v composer > /dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 + fi + rm -rf /tmp/mokocli + CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git + git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli + cd /tmp/mokocli + composer install --no-dev --no-interaction --quiet + echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV + fi + + - name: Rename branch to rc + run: | + php ${MOKO_CLI}/branch_rename.php \ + --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ + --pr "${{ github.event.pull_request.number }}" + + - name: Checkout rc and configure git + run: | + git fetch origin rc + git checkout rc + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + + - name: Publish RC release + run: | + php ${MOKO_CLI}/release_publish.php \ + --path . --stability rc --bump minor --branch rc \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" + + - name: Update RC release notes from CHANGELOG.md + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + # Extract [Unreleased] section from changelog + NOTES="" + if [ -f "CHANGELOG.md" ]; then + NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) + fi + [ -z "$NOTES" ] && NOTES="Release candidate" + + # Find the RC release and update its body + RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/releases/tags/release-candidate" \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -n "$RELEASE_ID" ]; then + python3 -c " + import json, urllib.request + body = open('/dev/stdin').read() + payload = json.dumps({'body': body}).encode() + req = urllib.request.Request( + '${API_BASE}/releases/${RELEASE_ID}', + data=payload, method='PATCH', + headers={ + 'Authorization': 'token ${TOKEN}', + 'Content-Type': 'application/json' + }) + urllib.request.urlopen(req) + " <<< "$NOTES" + echo "RC release notes updated from CHANGELOG.md" + fi + + - name: Summary + if: always() + run: | + echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY + echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY + + # ── Merged PR β†’ Build & Release (or promote RC to stable) ───────────────────────── + release: + name: Build & Release Pipeline + runs-on: release + if: >- + github.event.pull_request.merged == true || + (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 0 + + - name: Configure git for bot pushes + run: | + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + + - name: Check for merge conflict markers + run: | + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + if [ -n "$CONFLICTS" ]; then + echo "::error::Merge conflict markers found β€” aborting release" + echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "No conflict markers found" + + - name: Setup mokocli tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' + run: | + if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then + echo Using pre-installed /opt/mokocli + echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV + else + echo Falling back to fresh clone + if ! command -v composer > /dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 + fi + rm -rf /tmp/mokocli + CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git + git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli + cd /tmp/mokocli + composer install --no-dev --no-interaction --quiet + echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV + fi + + - name: "Detect platform" + id: platform + run: | + php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true + php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true + + - name: "Determine version bump level" + id: bump + run: | + # Fix/patch branches: version was already bumped by pre-release, just strip suffix + # Feature/dev branches: bump minor for the new stable release + HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}" + case "$HEAD_REF" in + fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;; + *) BUMP="minor" ;; + esac + echo "level=${BUMP}" >> "$GITHUB_OUTPUT" + echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})" + + - name: "Publish stable release" + run: | + BUMP_FLAG="" + if [ "${{ steps.bump.outputs.level }}" != "none" ]; then + BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}" + fi + php ${MOKO_CLI}/release_publish.php \ + --path . --stability stable ${BUMP_FLAG} --branch main \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" + + - name: "Read published version" + id: version + run: | + VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "") + VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') + [ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + PLATFORM="${{ steps.platform.outputs.platform }}" + if [[ "$PLATFORM" == joomla* ]]; then + echo "tag=stable" >> "$GITHUB_OUTPUT" + echo "release_tag=stable" >> "$GITHUB_OUTPUT" + else + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT" + fi + echo "branch=main" >> "$GITHUB_OUTPUT" + echo "Published version: ${VERSION}" + + - name: "Create semver tag for non-Joomla repos" + id: semver + if: | + steps.version.outputs.skip != 'true' && + !startsWith(steps.platform.outputs.platform, 'joomla') + run: | + VERSION="${{ steps.version.outputs.version }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + SEMVER_TAG="v${VERSION}" + + echo "Creating semver tag: ${SEMVER_TAG}" + + # Create the git tag via API + HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \ + -X POST -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/tags" \ + -d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000") + + if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then + echo "Created semver tag: ${SEMVER_TAG}" + elif [ "$HTTP_CODE" = "409" ]; then + echo "Semver tag ${SEMVER_TAG} already exists (skipped)" + else + echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})" + fi + + echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT" + + - name: Update release notes and promote changelog + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + # Get the stable release info (version and ID) + RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}') + RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true) + # Extract version from release name (e.g. "06.17.00" or "v06.17.00") + VERSION=$(python3 -c " + import json, sys, re + r = json.load(sys.stdin) + name = r.get('name', '') + m = re.search(r'(\d+\.\d+\.\d+)', name) + print(m.group(1) if m else '') + " <<< "$RELEASE_JSON" 2>/dev/null || true) + + # Extract [Unreleased] section from changelog + NOTES="" + if [ -f "CHANGELOG.md" ]; then + NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) + fi + [ -z "$NOTES" ] && NOTES="Stable release" + + # Update release body via API + if [ -n "$RELEASE_ID" ]; then + python3 -c " + import json, urllib.request + body = open('/dev/stdin').read() + payload = json.dumps({'body': body}).encode() + req = urllib.request.Request( + '${API_BASE}/releases/${RELEASE_ID}', + data=payload, method='PATCH', + headers={ + 'Authorization': 'token ${TOKEN}', + 'Content-Type': 'application/json' + }) + urllib.request.urlopen(req) + " <<< "$NOTES" + echo "Release notes updated from CHANGELOG.md" + fi + + # Promote [Unreleased] β†’ [version] in CHANGELOG.md and reset + if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then + DATE=$(date +%Y-%m-%d) + python3 -c " + import sys + version, date = sys.argv[1], sys.argv[2] + content = open('CHANGELOG.md').read() + old = '## [Unreleased]' + new = f'## [Unreleased]\n\n## [{version}] --- {date}' + content = content.replace(old, new, 1) + open('CHANGELOG.md', 'w').write(content) + " "$VERSION" "$DATE" + git add CHANGELOG.md + git commit -m "chore: promote changelog [Unreleased] β†’ [${VERSION}]" || true + git push origin main || true + echo "Changelog promoted: [Unreleased] β†’ [${VERSION}]" + fi + + # -- STEP 9: Mirror to GitHub (stable only) -------------------------------- + - name: "Step 9: Mirror release to GitHub" + if: >- + steps.version.outputs.skip != 'true' && + secrets.GH_MIRROR_TOKEN != '' + continue-on-error: true + run: | + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + RELEASE_TAG="${{ steps.version.outputs.release_tag }}" + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/release_mirror.php \ + --version "$VERSION" --tag "$RELEASE_TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \ + --branch main 2>&1 || true + echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY + + # -- STEP 10: Sync main branch to GitHub mirror ---------------------------- + - name: "Step 10: Push main to GitHub mirror" + if: >- + steps.version.outputs.skip != 'true' && + secrets.GH_MIRROR_TOKEN != '' + continue-on-error: true + run: | + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) + GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) + git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ + git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" + git fetch origin main --depth=1 + git push github origin/main:refs/heads/main --force 2>/dev/null \ + && echo "main branch pushed to GitHub mirror" \ + || echo "WARNING: GitHub mirror push failed" + + - name: "Step 11: Delete rc branch and recreate dev from main" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + # Delete rc branch (ephemeral β€” created by promote-rc) + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/branches/rc" 2>/dev/null \ + && echo "Deleted rc branch" || echo "rc branch not found" + + # Delete dev branch + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${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 "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY + + - name: "Step 12: Create version branch from main" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + BRANCH_NAME="version/${VERSION}" + MAIN_SHA=$(git rev-parse HEAD) + + # Delete old version branch if it exists (same version re-release) + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}" + + # Create version/XX.YY.ZZ from main + curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed" + + echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY + + + + # -- Dolibarr post-release: Reset dev version ----------------------------- + - name: "Post-release: Reset dev version" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/version_reset_dev.php \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ + --branch dev --path . 2>&1 || true + + # -- Summary -------------------------------------------------------------- + - name: Pipeline Summary + 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/branch-cleanup.yml b/.mokogitea/workflows/branch-cleanup.yml new file mode 100644 index 0000000..9d884e7 --- /dev/null +++ b/.mokogitea/workflows/branch-cleanup.yml @@ -0,0 +1,48 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Universal +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /.mokogitea/workflows/branch-cleanup.yml +# VERSION: 01.00.00 +# BRIEF: Delete feature branches after PR merge + +name: "Branch Cleanup" + +on: + pull_request: + types: [closed] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + cleanup: + name: Delete merged branch + runs-on: ubuntu-latest + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.head.ref != 'dev' && + github.event.pull_request.head.ref != 'main' + + steps: + - name: Delete source branch + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches" + ENCODED=$(php -r "echo rawurlencode('${BRANCH}');") + + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \ + -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ + "${API}/${ENCODED}" 2>/dev/null || true) + + if [ "$STATUS" = "204" ]; then + echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + elif [ "$STATUS" = "404" ]; then + echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + else + echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})" + fi diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml new file mode 100644 index 0000000..5f7c1d7 --- /dev/null +++ b/.mokogitea/workflows/cascade-dev.yml @@ -0,0 +1,10 @@ +# DISABLED β€” auto-release Step 11 recreates dev from main after every release. +# Cascade-dev is redundant and causes version conflicts when both main and dev +# have different version numbers in templateDetails.xml / manifest.xml. +name: "Cascade Main β†’ Dev (DISABLED)" +on: workflow_dispatch +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: echo "Cascade disabled β€” auto-release handles dev recreation" diff --git a/.mokogitea/workflows/ci-generic.yml b/.mokogitea/workflows/ci-generic.yml new file mode 100644 index 0000000..18ae768 --- /dev/null +++ b/.mokogitea/workflows/ci-generic.yml @@ -0,0 +1,191 @@ +# 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/Template-Generic +# PATH: /.gitea/workflows/ci-generic.yml +# VERSION: 01.00.00 +# BRIEF: CI pipeline β€” lint, validate, and test for generic projects (PHP + Node.js) + +name: "Generic: Project CI" + +on: + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + # ── Lint & Validate ─────────────────────────────────────────────────── + lint: + name: Lint & Validate + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect toolchain + id: detect + run: | + HAS_PHP=false + HAS_NODE=false + [ -f "composer.json" ] && HAS_PHP=true + [ -f "package.json" ] && HAS_NODE=true + echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT" + echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT" + echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE" + + - name: Setup PHP + if: steps.detect.outputs.has_php == 'true' + run: | + if ! command -v php &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1 + fi + php -v + + - name: Setup Node.js + if: steps.detect.outputs.has_node == 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install PHP dependencies + if: steps.detect.outputs.has_php == 'true' + run: | + if [ -f "composer.json" ]; then + composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true + fi + + - name: Install Node.js dependencies + if: steps.detect.outputs.has_node == 'true' + run: | + if [ -f "package.json" ]; then + npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true + fi + + - name: PHP syntax check + if: steps.detect.outputs.has_php == 'true' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then + echo "::error file=${file}::PHP syntax error" + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0) + + echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY + if [ "$ERRORS" -eq 0 ]; then + echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY + else + echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + - name: TypeScript/JavaScript lint + if: steps.detect.outputs.has_node == 'true' + run: | + if [ -f "node_modules/.bin/eslint" ]; then + npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; } + echo "## ESLint" >> $GITHUB_STEP_SUMMARY + echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY + elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then + echo "::warning::ESLint config found but eslint not installed" + else + echo "No ESLint configured β€” skipping" + fi + + - name: TypeScript compile check + if: steps.detect.outputs.has_node == 'true' + run: | + if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then + npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; } + echo "## TypeScript" >> $GITHUB_STEP_SUMMARY + echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY + fi + + - name: PHPStan static analysis + if: steps.detect.outputs.has_php == 'true' + run: | + if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then + vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; } + fi + + # ── Tests ───────────────────────────────────────────────────────────── + test: + name: Tests + runs-on: ubuntu-latest + needs: lint + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect toolchain + id: detect + run: | + HAS_PHP=false + HAS_NODE=false + [ -f "composer.json" ] && HAS_PHP=true + [ -f "package.json" ] && HAS_NODE=true + echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT" + echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT" + + - name: Setup PHP + if: steps.detect.outputs.has_php == 'true' + run: | + if ! command -v php &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1 + fi + + - name: Setup Node.js + if: steps.detect.outputs.has_node == 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: | + [ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true + [ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; } + + - name: Run PHP tests + if: steps.detect.outputs.has_php == 'true' + run: | + if [ -f "vendor/bin/phpunit" ]; then + vendor/bin/phpunit --testdox 2>&1 + echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY + echo "Tests passed." >> $GITHUB_STEP_SUMMARY + elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then + echo "::warning::PHPUnit config found but phpunit not installed" + else + echo "No PHPUnit configured β€” skipping" + fi + + - name: Run Node.js tests + if: steps.detect.outputs.has_node == 'true' + run: | + if jq -e '.scripts.test' package.json > /dev/null 2>&1; then + npm test 2>&1 + echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY + echo "Tests passed." >> $GITHUB_STEP_SUMMARY + else + echo "No test script in package.json β€” skipping" + fi + + - name: Build check + run: | + if [ -f "Makefile" ]; then + make build 2>&1 || echo "::warning::Build failed or not configured" + elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then + npm run build 2>&1 || echo "::warning::Build failed" + fi diff --git a/.mokogitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml new file mode 100644 index 0000000..727f661 --- /dev/null +++ b/.mokogitea/workflows/ci-joomla.yml @@ -0,0 +1,903 @@ +# 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@v4 + + - name: Setup PHP + run: | + if ! command -v php &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + php -v && composer --version + + - name: Setup mokocli tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }} + MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} + run: | + if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then + echo "mokocli already available on runner β€” skipping clone" + else + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \ + /tmp/mokocli 2>/dev/null || echo "mokocli clone skipped β€” continuing without it" + fi + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || 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 + for TAG in name version author; do + if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then + echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY + fi + done + + # Namespace is required for components/plugins but not packages + EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + if [ "$EXT_TYPE" != "package" ]; then + if ! grep -q "/dev/null; then + echo "Missing required tag: \`\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Found required tag: \`\`" >> $GITHUB_STEP_SUMMARY + fi + else + echo "Package extension β€” \`\` not required." >> $GITHUB_STEP_SUMMARY + fi + 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 + + - name: Check config.xml and access.xml for components + run: | + echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Find all component manifests (XML with type="component") + COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l ']*type="component"' {} ; 2>/dev/null || true) + + if [ -z "$COMP_MANIFESTS" ]; then + echo "No component extensions found β€” skipping." >> $GITHUB_STEP_SUMMARY + else + for MANIFEST in $COMP_MANIFESTS; do + COMP_DIR=$(dirname "$MANIFEST") + COMP_NAME=$(basename "$COMP_DIR") + echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY + + # Check access.xml exists + ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$ACCESS_FILE" ]; then + echo "- Missing `access.xml` β€” ACL permissions will not work." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + if command -v php &> /dev/null; then + if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then + echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + for ACTION in core.admin core.manage; do + if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then + echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY + fi + fi + fi + + # Check config.xml exists + CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$CONFIG_FILE" ]; then + echo "- Missing `config.xml` β€” component Options page will be empty." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + if command -v php &> /dev/null; then + if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then + echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY + fi + fi + fi + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: SQL schema validation + run: | + echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Find SQL files in source/htdocs + SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$SQL_FILES" ]; then + echo "No SQL files found β€” skipping." >> $GITHUB_STEP_SUMMARY + else + echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY + + for FILE in $SQL_FILES; do + # Basic syntax check: balanced parentheses, no empty files + SIZE=$(wc -c < "$FILE" | tr -d ' ') + if [ "$SIZE" -eq 0 ]; then + echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + continue + fi + + # Check for common SQL errors + if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then + echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + continue + fi + + echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY + done + + # Check update SQL files follow version numbering pattern + UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1) + if [ -n "$UPDATE_DIR" ]; then + BAD_NAMES=0 + for UFILE in "$UPDATE_DIR"/*.sql; do + [ ! -f "$UFILE" ] && continue + BASENAME=$(basename "$UFILE" .sql) + if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then + echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY + BAD_NAMES=$((BAD_NAMES + 1)) + fi + done + if [ "$BAD_NAMES" -gt 0 ]; then + ERRORS=$((ERRORS + BAD_NAMES)) + fi + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Manifest file references check + run: | + echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No manifest found β€” skipping." >> $GITHUB_STEP_SUMMARY + else + MANIFEST_DIR=$(dirname "$MANIFEST") + + # Check references + FILENAMES=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) + for F in $FILENAMES; do + if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then + echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + + # Check references + FOLDERS=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) + for F in $FOLDERS; do + if [ ! -d "${MANIFEST_DIR}/${F}" ]; then + echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + + # Check references in package manifests (ZIP files won't exist in source) + EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + if [ "$EXT_TYPE" != "package" ]; then + FILES=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) + for F in $FILES; do + if [ ! -f "${MANIFEST_DIR}/${F}" ]; then + echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Form XML validation + run: | + echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$FORM_FILES" ]; then + echo "No form XML files found β€” skipping." >> $GITHUB_STEP_SUMMARY + else + echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY + for FILE in $FORM_FILES; do + if command -v php &> /dev/null; then + if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then + echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + # Check for valid Joomla form structure + if ! grep -qE '/dev/null; then + echo "- \`${FILE}\`: no \`
\`, \`\`, or \`
\` elements found" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY + fi + fi + fi + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Deprecated Joomla API check + continue-on-error: true + run: | + echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY + WARNINGS=0 + + SRC_DIR="" + for DIR in source/ src/ htdocs/; do + [ -d "$DIR" ] && SRC_DIR="$DIR" && break + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found β€” skipping." >> $GITHUB_STEP_SUMMARY + else + # Joomla 3/4 deprecated patterns that break in Joomla 6 + PATTERNS=( + 'JFactory::' + 'JText::' + 'JHtml::' + 'JRoute::' + 'JUri::' + 'JLog::' + 'JTable::' + 'JInput' + 'CMSFactory::\$application' + 'JApplicationCms' + ) + + for PATTERN in "${PATTERNS[@]}"; do + HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true) + if [ -n "$HITS" ]; then + COUNT=$(echo "$HITS" | wc -l) + echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + COUNT)) + fi + done + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$WARNINGS" -gt 0 ]; then + echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY + else + echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Template output escaping check + continue-on-error: true + run: | + echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY + WARNINGS=0 + + TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$TMPL_FILES" ]; then + echo "No template files found β€” skipping." >> $GITHUB_STEP_SUMMARY + else + echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY + + for FILE in $TMPL_FILES; do + # Check for unescaped output: or echo $var without escape() + UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true) + if [ -n "$UNESCAPED" ]; then + HITS=$(echo "$UNESCAPED" | wc -l) + echo "- \`${FILE}\`: ${HITS} unescaped \`\` output(s) β€” use \`escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + HITS)) + fi + + # Check for echo without escaping in template context + RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true) + if [ -n "$RAW_ECHO" ]; then + HITS=$(echo "$RAW_ECHO" | wc -l) + echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` β€” consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + HITS)) + fi + done + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$WARNINGS" -gt 0 ]; then + echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY + else + echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Namespace consistency check + run: | + echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Find component/plugin manifests with tags + MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '/dev/null || true) + + if [ -z "$MANIFESTS" ]; then + echo "No manifests with \`\` found β€” skipping." >> $GITHUB_STEP_SUMMARY + else + for MANIFEST in $MANIFESTS; do + NS_PATH=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1) + [ -z "$NS_PATH" ] && continue + MANIFEST_DIR=$(dirname "$MANIFEST") + + echo "Manifest: \`${MANIFEST}\` β†’ namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY + + # Check PHP files have matching namespace + while IFS= read -r -d '' PHP_FILE; do + FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1) + [ -z "$FILE_NS" ] && continue + + # Namespace should start with the manifest namespace path + if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then + echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null) + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: SPDX license header check + continue-on-error: true + run: | + echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY + MISSING=0 + + SRC_DIR="" + for DIR in source/ src/ htdocs/; do + [ -d "$DIR" ] && SRC_DIR="$DIR" && break + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found β€” skipping." >> $GITHUB_STEP_SUMMARY + else + TOTAL=0 + while IFS= read -r -d '' FILE; do + TOTAL=$((TOTAL + 1)) + if ! head -10 "$FILE" | grep -qi "SPDX"; then + echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY + MISSING=$((MISSING + 1)) + fi + done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0) + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$MISSING" -gt 0 ]; then + echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY + else + echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Service provider check + run: | + echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$PROVIDERS" ]; then + echo "No service providers found β€” skipping." >> $GITHUB_STEP_SUMMARY + else + for FILE in $PROVIDERS; do + # Must return a ServiceProviderInterface + if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then + echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY + fi + + # Must have return statement + if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then + echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + release-readiness: + name: Release Readiness Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.base_ref == 'main' + continue-on-error: true + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - 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@v4 + + - name: Setup PHP ${{ matrix.php }} + run: | + if ! command -v php &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + php -v && composer --version + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || 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@v4 + + - name: Setup PHP + run: | + if ! command -v php &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + php -v && composer --version + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}' + run: | + if [ -f "composer.json" ]; then + composer install --no-interaction --prefer-dist --optimize-autoloader + 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 + + pre-release: + name: Build RC Pre-Release + runs-on: ubuntu-latest + needs: [lint-and-validate, test] + if: github.event_name == 'pull_request' + + steps: + - name: Trigger pre-release build + env: + GA_TOKEN: ${{ secrets.GA_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.head_ref }} + run: | + curl -s -X POST \ + "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \ + -H "Authorization: token ${GA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" + echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY + echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY 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/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml new file mode 100644 index 0000000..196cf0c --- /dev/null +++ b/.mokogitea/workflows/gitleaks.yml @@ -0,0 +1,92 @@ +# 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: + 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/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml new file mode 100644 index 0000000..d06868a --- /dev/null +++ b/.mokogitea/workflows/issue-branch.yml @@ -0,0 +1,73 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Automation +# VERSION: 01.00.19 +# BRIEF: Auto-create feature branch when an issue is opened + +name: "Universal: Issue Branch" + +on: + issues: + types: [opened] + +permissions: + contents: write + issues: write + +env: + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + +jobs: + create-branch: + name: Create feature branch + runs-on: ubuntu-latest + steps: + - name: Create branch and comment + run: | + TOKEN="${{ secrets.GA_TOKEN }}" + API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" + ISSUE_NUM="${{ github.event.issue.number }}" + ISSUE_TITLE="${{ github.event.issue.title }}" + + # Build slug from title: lowercase, replace non-alnum with dash, trim + SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40) + BRANCH="feature/${ISSUE_NUM}-${SLUG}" + + # Check dev branch exists + DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${TOKEN}" \ + "${API}/branches/dev" 2>/dev/null || echo "000") + + if [ "${DEV_EXISTS}" != "200" ]; then + echo "No dev branch -- skipping" + exit 0 + fi + + # Create branch from dev + HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/branches" \ + -d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000") + + if [ "${HTTP}" = "201" ]; then + echo "Created branch: ${BRANCH}" + + # Comment on issue with branch link + REPO_URL="${GITEA_URL}/${{ github.repository }}" + BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`" + + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/issues/${ISSUE_NUM}/comments" \ + -d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1 + + echo "Commented on issue #${ISSUE_NUM}" + else + echo "Failed to create branch (HTTP ${HTTP}) -- may already exist" + fi diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml new file mode 100644 index 0000000..51dfcb5 --- /dev/null +++ b/.mokogitea/workflows/notify.yml @@ -0,0 +1,70 @@ +# 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" + 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..b1037e7 --- /dev/null +++ b/.mokogitea/workflows/pr-check.yml @@ -0,0 +1,534 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/universal/pr-check.yml.template +# VERSION: 09.23.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 + ;; + patch/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then + ALLOWED=false + REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'" + fi + ;; + hotfix/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" + fi + ;; + rc) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="RC branch can only merge into '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 + + # ── Secret Scanning ────────────────────────────────────────────────── + gitleaks: + name: Secret Scan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Gitleaks + run: | + GITLEAKS_VERSION="8.21.2" + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /usr/local/bin gitleaks + + - name: Scan PR commits for secrets + run: | + if gitleaks detect --source . --verbose \ + --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then + echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY + else + echo "::error::Potential secrets detected in PR commits" + exit 1 + fi + + # ── Code Validation ──────────────────────────────────────────────────── + validate: + name: Validate PR + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check for merge conflict markers + run: | + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + if [ -n "$CONFLICTS" ]; then + echo "::error::Merge conflict markers found in source files" + echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "No conflict markers found" + + - name: Detect platform + id: platform + run: | + # 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: Joomla JEXEC guard check + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + # Skip vendor, node_modules, and index.html stub files + case "$file" in ./vendor/*|./node_modules/*) continue ;; esac + # Check first 10 lines for JEXEC or JPATH guard + if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then + echo "::error file=${file}::Missing JEXEC guard: ${file}" + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0) + if [ "$ERRORS" -gt 0 ]; then + echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard" + echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY + echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "JEXEC guard: OK" + + - name: Joomla directory listing protection + if: steps.platform.outputs.platform == 'joomla' + run: | + MISSING=0 + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && exit 0 + while IFS= read -r dir; do + if [ ! -f "${dir}/index.html" ]; then + echo "::warning::Missing index.html in ${dir} (directory listing protection)" + MISSING=$((MISSING + 1)) + fi + done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*") + if [ "$MISSING" -gt 0 ]; then + echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY + echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY + fi + echo "Directory protection: ${MISSING} missing (advisory)" + + - name: Joomla script file and asset checks + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && exit 0 + MANIFEST_DIR=$(dirname "$MANIFEST") + + # Check scriptfile exists if declared + SCRIPTFILE=$(sed -n 's/.*\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null) + if [ -n "$SCRIPTFILE" ]; then + if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then + echo "::error::Manifest declares ${SCRIPTFILE} but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}" + ERRORS=$((ERRORS + 1)) + else + echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)" + fi + fi + + # Require joomla.asset.json and validate it + ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$ASSET_JSON" ]; then + echo "::error::joomla.asset.json not found β€” Joomla asset system is required" + ERRORS=$((ERRORS + 1)) + else + if command -v php &> /dev/null; then + php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || { + echo "::error::joomla.asset.json is not valid JSON" + ERRORS=$((ERRORS + 1)) + } + fi + echo "joomla.asset.json: valid" + fi + + # Validate all XML files in src/ are well-formed + XML_ERRORS=0 + if command -v php &> /dev/null; then + while IFS= read -r -d '' xmlfile; do + if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then + XML_ERRORS=$((XML_ERRORS + 1)) + fi + done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0) + fi + if [ "$XML_ERRORS" -gt 0 ]; then + echo "::error::${XML_ERRORS} XML file(s) are malformed" + ERRORS=$((ERRORS + 1)) + else + echo "XML well-formedness: OK" + fi + + [ "$ERRORS" -gt 0 ] && exit 1 + echo "Joomla asset checks: OK" + + - name: Validate platform manifest + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + 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 + # Block legacy raw/branch update server URLs on MokoGitea + RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true) + if [ -n "$RAW_URLS" ]; then + echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" + echo "$RAW_URLS" + exit 1 + fi + echo "Joomla manifest valid" + ;; + dolibarr) + 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: Validate Joomla language files + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + WARNINGS=0 + + # Require both en-GB and en-US language directories + LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$LANG_ROOT" ]; then + echo "No language/ directory found β€” skipping" + exit 0 + fi + + if [ ! -d "$LANG_ROOT/en-GB" ]; then + echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)" + ERRORS=$((ERRORS + 1)) + fi + if [ ! -d "$LANG_ROOT/en-US" ]; then + echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)" + ERRORS=$((ERRORS + 1)) + fi + + # Check that en-GB and en-US have matching .ini files + if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then + for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do + [ ! -f "$GB_INI" ] && continue + US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")" + if [ ! -f "$US_INI" ]; then + echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US" + ERRORS=$((ERRORS + 1)) + fi + done + for US_INI in "$LANG_ROOT/en-US"/*.ini; do + [ ! -f "$US_INI" ] && continue + GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")" + if [ ! -f "$GB_INI" ]; then + echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB" + ERRORS=$((ERRORS + 1)) + fi + done + fi + + # Find all .ini language files + INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null) + if [ -z "$INI_FILES" ]; then + echo "No .ini language files found" + [ "$ERRORS" -gt 0 ] && exit 1 + exit 0 + fi + + echo "Found $(echo "$INI_FILES" | wc -l) language file(s)" + + for FILE in $INI_FILES; do + FNAME=$(basename "$FILE") + LINENUM=0 + SEEN_KEYS="" + + while IFS= read -r line || [ -n "$line" ]; do + LINENUM=$((LINENUM + 1)) + + # Skip empty lines and comments + [ -z "$line" ] && continue + echo "$line" | grep -qE '^\s*;' && continue + echo "$line" | grep -qE '^\s*$' && continue + + # Must match KEY="VALUE" format + if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then + echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}" + ERRORS=$((ERRORS + 1)) + continue + fi + + # Extract key and check for duplicates + KEY=$(echo "$line" | sed 's/=.*//') + if echo "$SEEN_KEYS" | grep -qx "$KEY"; then + echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}" + ERRORS=$((ERRORS + 1)) + fi + SEEN_KEYS="${SEEN_KEYS} + ${KEY}" + done < "$FILE" + + echo " ${FILE}: checked ${LINENUM} lines" + done + + # Cross-check en-GB vs en-US key consistency + GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1) + US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1) + + if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then + for GB_FILE in "$GB_DIR"/*.ini; do + [ ! -f "$GB_FILE" ] && continue + FNAME=$(basename "$GB_FILE") + US_FILE="$US_DIR/$FNAME" + [ ! -f "$US_FILE" ] && continue + + GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort) + US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort) + + # Keys in en-GB but not en-US + MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_US" ]; then + echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:" + echo "$MISSING_US" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + + # Keys in en-US but not en-GB + MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_GB" ]; then + echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:" + echo "$MISSING_GB" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + done + fi + + { + echo "### Language File Validation" + echo "| Metric | Count |" + echo "|---|---|" + echo "| Files checked | $(echo "$INI_FILES" | wc -l) |" + echo "| Errors | ${ERRORS} |" + echo "| Warnings | ${WARNINGS} |" + } >> $GITHUB_STEP_SUMMARY + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Language validation failed with ${ERRORS} error(s)" + exit 1 + fi + echo "Language files: OK (${WARNINGS} warning(s))" + + - name: Check changelog has unreleased entry + run: | + if [ ! -f "CHANGELOG.md" ]; then + echo "::warning::No CHANGELOG.md found" + exit 0 + fi + # Check for content under [Unreleased] section + if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md missing [Unreleased] section" + exit 1 + fi + # Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased + UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true) + if [ "$UNRELEASED_CONTENT" -eq 0 ]; then + echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes." + echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY + echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY + echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]" + + - name: Verify package source + run: | + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + if [ ! -d "$SOURCE_DIR" ]; then + echo "::warning::No src/ or htdocs/ directory" + exit 0 + fi + FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l) + echo "Source: ${FILE_COUNT} files" + [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } + + # ── Pre-Release RC Build ───────────────────────────────────────────────── + pre-release: + name: Build RC Package + runs-on: ubuntu-latest + needs: [branch-policy, validate] + + steps: + - name: Trigger RC pre-release + env: + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.head_ref }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" + echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY + echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY + + # ── Issue Reporter ────────────────────────────────────────────────────── + report-issues: + name: Report Issues + runs-on: ubuntu-latest + needs: [branch-policy, validate] + if: >- + always() && + needs.validate.result == 'failure' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issue for PR validation failure" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + ./automation/ci-issue-reporter.sh \ + --gate "PR Validation" \ + --workflow "PR Check" \ + --severity error \ + --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." diff --git a/.mokogitea/workflows/pr-metadata-check.yml b/.mokogitea/workflows/pr-metadata-check.yml new file mode 100644 index 0000000..68b7589 --- /dev/null +++ b/.mokogitea/workflows/pr-metadata-check.yml @@ -0,0 +1,71 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Validation +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template +# VERSION: 01.00.00 +# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs + +name: "Joomla: Metadata Validation" + +on: + pull_request: + types: [opened, synchronize, reopened, converted_to_draft, ready_for_review] + +permissions: + contents: read + +env: + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +jobs: + validate-metadata: + name: "Validate Joomla Metadata" + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup mokocli tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then + echo Using pre-installed /opt/mokocli + echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV + else + echo Falling back to fresh clone + if ! command -v composer > /dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 + fi + rm -rf /tmp/mokocli + CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git + git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli + cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet + echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV + fi + + - name: Validate metadata against Joomla manifest + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + run: | + php ${MOKO_CLI}/joomla_metadata_validate.php \ + --path . \ + --token "${GITEA_TOKEN}" \ + --org "${GITEA_ORG}" \ + --repo "${GITEA_REPO}" \ + --api-base "${GITEA_URL}/api/v1" \ + --ci + + if [ $? -ne 0 ]; then + echo "::error::Joomla metadata mismatch β€” update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details." + exit 1 + fi diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml new file mode 100644 index 0000000..b34a311 --- /dev/null +++ b/.mokogitea/workflows/pre-release.yml @@ -0,0 +1,252 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /templates/workflows/universal/pre-release.yml.template +# VERSION: 05.01.00 +# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches + +name: "Universal: Pre-Release" + +on: + push: + branches: + - dev + - 'fix/**' + - 'patch/**' + - 'hotfix/**' + - 'bugfix/**' + - 'chore/**' + - alpha + - beta + - rc + workflow_dispatch: + inputs: + stability: + 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 || github.ref_name }})" + runs-on: release + if: >- + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.MOKOGITEA_TOKEN }} + ref: ${{ github.ref_name }} + + - name: Setup mokocli tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + # Use pre-installed /opt/mokocli if available (updated by cron every 6h) + if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then + echo Using pre-installed /opt/mokocli + echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV + else + echo Falling back to fresh clone + if ! command -v composer > /dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 + fi + rm -rf /tmp/mokocli + CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git + git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli + cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet + echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV + fi + + - name: Detect platform + id: platform + run: | + # Auto-detect and update platform if not set in manifest + php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true + php ${MOKO_CLI}/manifest_read.php --path . --github-output + + - name: Resolve metadata and bump version + id: meta + run: | + # Auto-detect stability from branch name on push, or use input on dispatch + if [ "${{ github.event_name }}" = "push" ]; then + case "${{ github.ref_name }}" in + rc) STABILITY="release-candidate" ;; + alpha) STABILITY="alpha" ;; + beta) STABILITY="beta" ;; + *) STABILITY="development" ;; + esac + else + STABILITY="${{ inputs.stability || 'development' }}" + fi + + case "$STABILITY" in + development) SUFFIX="-dev"; TAG="development" ;; + alpha) SUFFIX="-alpha"; TAG="alpha" ;; + beta) SUFFIX="-beta"; TAG="beta" ;; + release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; + esac + + # Bump version via CLI: patch for dev/alpha/beta, minor for RC + case "$STABILITY" in + release-candidate) BUMP="minor" ;; + *) BUMP="patch" ;; + esac + + php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true + + # Set stability suffix and verify consistency + VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01") + VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') + + php ${MOKO_CLI}/version_set_platform.php \ + --path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true + php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true + + # Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml + php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true + + # Append suffix for output + if [ -n "$SUFFIX" ]; then + VERSION="${VERSION}${SUFFIX}" + fi + + # Commit version bump + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + git add -A + git diff --cached --quiet || { + git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]" + git push origin HEAD 2>&1 + } + + # Auto-detect element via manifest_element.php + php ${MOKO_CLI}/manifest_element.php \ + --path . --version "$VERSION" --stability "$STABILITY" \ + --repo "${GITEA_REPO}" --github-output + + # Read back element outputs + EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) + ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + [ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip" + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" + echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" + echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" + + echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ===" + + - name: Create release + id: release + run: | + TAG="${{ steps.meta.outputs.tag }}" + VERSION="${{ steps.meta.outputs.version }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/release_create.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease + + - name: Update release notes from CHANGELOG.md + run: | + TAG="${{ steps.meta.outputs.tag }}" + VERSION="${{ steps.meta.outputs.version }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + # Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading) + if [ -f "CHANGELOG.md" ]; then + NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) + [ -z "$NOTES" ] && NOTES="Release ${VERSION}" + else + NOTES="Release ${VERSION}" + fi + + # Update release body via API + RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ + "${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -n "$RELEASE_ID" ]; then + python3 -c " + import json, urllib.request + body = open('/dev/stdin').read() + payload = json.dumps({'body': body}).encode() + req = urllib.request.Request( + '${API_BASE}/releases/${RELEASE_ID}', + data=payload, method='PATCH', + headers={ + 'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}', + 'Content-Type': 'application/json' + }) + urllib.request.urlopen(req) + " <<< "$NOTES" + echo "Release notes updated from CHANGELOG.md" + fi + + - name: Build package and upload + id: package + run: | + VERSION="${{ steps.meta.outputs.version }}" + TAG="${{ steps.meta.outputs.tag }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/release_package.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --output /tmp || true + + # updates.xml is generated dynamically by MokoGitea license server + # No need to build, commit, or sync updates.xml from workflows + + - name: "Delete lesser pre-release channels (cascade)" + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + php ${MOKO_CLI}/release_cascade.php \ + --stability "${{ steps.meta.outputs.stability }}" \ + --token "${TOKEN}" \ + --api-base "${API_BASE}" + + - name: Summary + if: always() + run: | + VERSION="${{ steps.meta.outputs.version }}" + STABILITY="${{ steps.meta.outputs.stability }}" + ZIP_NAME="${{ steps.meta.outputs.zip_name }}" + SHA256="${{ steps.package.outputs.sha256_zip }}" + echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY + echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY + echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/rc-revert.yml b/.mokogitea/workflows/rc-revert.yml new file mode 100644 index 0000000..5e61de8 --- /dev/null +++ b/.mokogitea/workflows/rc-revert.yml @@ -0,0 +1,66 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Universal +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /.mokogitea/workflows/rc-revert.yml +# VERSION: 09.23.00 +# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge + +name: "RC Revert" + +on: + pull_request: + types: [closed] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + revert: + name: Rename rc/ back to dev/ + runs-on: ubuntu-latest + if: >- + github.event.pull_request.merged == false && + startsWith(github.event.pull_request.head.ref, 'rc/') + + steps: + - name: Rename branch + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + SUFFIX="${BRANCH#rc/}" + DEV_BRANCH="dev/${SUFFIX}" + API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + # Create dev/ branch from rc/ branch + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \ + "${API}" 2>/dev/null || true) + + if [ "$STATUS" = "201" ]; then + echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY + else + echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})" + exit 1 + fi + + # Delete rc/ branch + ENCODED=$(php -r "echo rawurlencode('${BRANCH}');") + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \ + -H "Authorization: token ${TOKEN}" \ + "${API}/${ENCODED}" 2>/dev/null || true) + + if [ "$STATUS" = "204" ]; then + echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + else + echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})" + fi + + echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY + echo "${BRANCH} β†’ ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml new file mode 100644 index 0000000..6a25f5b --- /dev/null +++ b/.mokogitea/workflows/repo-health.yml @@ -0,0 +1,712 @@ +# ============================================================================ +# 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: mokocli.Validation +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli +# PATH: /templates/workflows/joomla/repo_health.yml.template +# VERSION: 09.23.00 +# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts. +# ============================================================================ + +name: "Generic: Repo Health" + +defaults: + run: + shell: bash + +on: + workflow_dispatch: + inputs: + profile: + description: 'Validation profile: all, scripts, or repo' + required: true + default: all + type: choice + options: + - all + - scripts + - repo + pull_request: + branches: + - main + +permissions: + contents: read + +env: + # 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,.mokogitea/workflows/ + REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/ + REPO_DISALLOWED_DIRS: + REPO_DISALLOWED_FILES: TODO.md,todo.md + + # Extended checks toggles + EXTENDED_CHECKS: "true" + + # File / directory variables + DOCS_INDEX: docs/docs-index.md + SCRIPT_DIR: scripts + WORKFLOWS_DIR: .mokogitea/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.MOKOGITEA_TOKEN || secrets.MOKOGITEA_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 + + 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|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'repo' ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes scripts governance' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + if [ ! -d "${SCRIPT_DIR}" ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' 'Status: OK (advisory)' + printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi + IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}" + + missing_dirs=() + unapproved_dirs=() + + for d in "${required_dirs[@]}"; do + req="${d%/}" + [ ! -d "${req}" ] && missing_dirs+=("${req}/") + done + + while IFS= read -r d; do + allowed=false + for a in "${allowed_dirs[@]}"; do + a_norm="${a%/}" + [ "${d%/}" = "${a_norm}" ] && allowed=true + done + [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/") + done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') + + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Area | Status | Notes |' + printf '%s\n' '|---|---|---|' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Required directories | Warning | Missing required subfolders |' + else + printf '%s\n' '| Required directories | OK | All required subfolders present |' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |' + else + printf '%s\n' '| Directory policy | OK | No unapproved directories |' + fi + + printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |' + printf '\n' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Missing required script directories:' + for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Missing required script directories: none.' + printf '\n' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Unapproved script directories detected:' + for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Unapproved script directories detected: none.' + printf '\n' + fi + + printf '%s\n' 'Scripts governance completed in advisory mode.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + repo_health: + name: Repository health + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Repository health checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'scripts' ]; then + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes repository health' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}" + IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}" + if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi + IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}" + + missing_required=() + missing_optional=() + + # Source directory: src/ or htdocs/ (either is valid for extension repos) + SOURCE_DIR="" + if [ -d "src" ]; then + SOURCE_DIR="src" + elif [ -d "htdocs" ]; then + SOURCE_DIR="htdocs" + elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then + # Platform/tooling repos don't need src/ + SOURCE_DIR="" + else + missing_required+=("src/ or htdocs/ (source directory required)") + fi + + for item in "${required_artifacts[@]}"; do + if printf '%s' "${item}" | grep -q '/$'; then + d="${item%/}" + [ ! -d "${d}" ] && missing_required+=("${item}") + else + [ ! -f "${item}" ] && missing_required+=("${item}") + fi + done + + for f in "${optional_files[@]}"; do + if printf '%s' "${f}" | grep -q '/$'; then + d="${f%/}" + [ ! -d "${d}" ] && missing_optional+=("${f}") + else + [ ! -f "${f}" ] && missing_optional+=("${f}") + fi + done + + for d in "${disallowed_dirs[@]}"; do + d_norm="${d%/}" + [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)") + done + + for f in "${disallowed_files[@]}"; do + [ -f "${f}" ] && missing_required+=("${f} (disallowed)") + done + + git fetch origin --prune + + dev_paths=() + dev_branches=() + + while IFS= read -r b; do + name="${b#origin/}" + if [ "${name}" = 'dev' ]; then + dev_branches+=("${name}") + else + dev_paths+=("${name}") + fi + done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//') + + if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then + missing_required+=("dev or dev/* branch") + fi + + content_warnings=() + + if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md missing '# Changelog' header") + fi + + if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)") + fi + + if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then + content_warnings+=("LICENSE does not look like a GPL text") + fi + + if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then + content_warnings+=("README.md missing expected brand keyword") + fi + + export PROFILE_RAW="${profile}" + export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")" + export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")" + export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")" + + report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}") + + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Metric | Value |' + printf '%s\n' '|---|---|' + printf '%s\n' "| Missing required | ${#missing_required[@]} |" + printf '%s\n' "| Missing optional | ${#missing_optional[@]} |" + printf '%s\n' "| Content warnings | ${#content_warnings[@]} |" + printf '\n' + + printf '%s\n' '### Guardrails report (JSON)' + printf '%s\n' '```json' + printf '%s\n' "${report_json}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${#missing_required[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing required repo artifacts' + for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done + printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + if [ "${#missing_optional[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing optional repo artifacts' + for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + if [ "${#content_warnings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Repo content warnings' + for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + # -- Joomla-specific checks -- + joomla_findings=() + + MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '/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 + + if [ -n "${SOURCE_DIR}" ]; then + INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site") + for dir in "${INDEX_DIRS[@]}"; do + if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then + joomla_findings+=("${dir}/index.html missing (directory listing protection)") + fi + done + fi + + if [ "${#joomla_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' '| Check | Status |' + printf '%s\n' '|---|---|' + for f in "${joomla_findings[@]}"; do + printf '%s\n' "| ${f} | Warning |" + done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + else + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' 'All Joomla-specific checks passed.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + extended_enabled="${EXTENDED_CHECKS:-true}" + extended_findings=() + + if [ "${extended_enabled}" = 'true' ]; then + if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then + : + else + extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)") + fi + + if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then + bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)" + if [ -n "${bad_refs}" ]; then + extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt") + { + printf '%s\n' '### Workflow pinning advisory' + printf '%s\n' 'Found uses: entries pinned to main/master:' + printf '%s\n' '```' + printf '%s\n' "${bad_refs}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + if [ -f "${DOCS_INDEX}" ]; then + missing_links="" + while IFS= read -r docline; do + for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do + case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac + linkpath="${link%%#*}" + linkpath="${linkpath%%\?*}" + [ -z "$linkpath" ] && continue + if [ "${linkpath:0:1}" = "/" ]; then + testpath="${linkpath#/}" + else + testpath="$(dirname "${DOCS_INDEX}")/${linkpath}" + fi + [ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} " + done + done < "${DOCS_INDEX}" + if [ -n "${missing_links}" ]; then + extended_findings+=("docs/docs-index.md contains broken relative links") + { + printf '%s\n' '### Docs index link integrity' + printf '%s\n' 'Broken relative links:' + for bl in ${missing_links}; do + printf '%s\n' "- ${bl}" + done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + if [ -d "${SCRIPT_DIR}" ]; then + if ! command -v shellcheck >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install -y shellcheck >/dev/null + fi + + sc_out='' + while IFS= read -r shf; do + [ -z "${shf}" ] && continue + out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)" + if [ -n "${out_one}" ]; then + sc_out="${sc_out}${out_one}\n" + fi + done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort) + + if [ -n "${sc_out}" ]; then + extended_findings+=("ShellCheck warnings detected (advisory)") + sc_head="$(printf '%s' "${sc_out}" | head -n 200)" + { + printf '%s\n' '### ShellCheck (advisory)' + printf '%s\n' '```' + printf '%s\n' "${sc_head}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + spdx_missing=() + IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}" + spdx_args=() + for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done + + while IFS= read -r f; do + [ -z "${f}" ] && continue + if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then + spdx_missing+=("${f}") + fi + done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true) + + if [ "${#spdx_missing[@]}" -gt 0 ]; then + extended_findings+=("SPDX header missing in some tracked files (advisory)") + { + printf '%s\n' '### SPDX header advisory' + printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):' + for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + stale_cutoff_days=180 + stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)" + if [ -n "${stale_branches}" ]; then + extended_findings+=("Stale remote branches detected (advisory)") + { + printf '%s\n' '### Git hygiene advisory' + printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):" + while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + { + printf '%s\n' '### Guardrails coverage matrix' + printf '%s\n' '| Domain | Status | Notes |' + printf '%s\n' '|---|---|---|' + printf '%s\n' '| Access control | OK | Admin-only execution gate |' + printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |' + printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |' + printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |' + printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |' + if [ "${extended_enabled}" = 'true' ]; then + if [ "${#extended_findings[@]}" -gt 0 ]; then + printf '%s\n' '| Extended checks | Warning | See extended findings below |' + else + printf '%s\n' '| Extended checks | OK | No findings |' + fi + else + printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |' + fi + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Extended findings (advisory)' + for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}" + + + site-health: + name: Site Health + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + + - name: Uptime check + if: env.URLS != '' + run: | + echo "$URLS" > /tmp/urls.txt + php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down" + rm -f /tmp/urls.txt + env: + URLS: ${{ vars.MONITORED_URLS }} + + - name: SSL certificate check + if: env.DOMAINS != '' + run: | + echo "$DOMAINS" > /tmp/domains.txt + php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon" + rm -f /tmp/domains.txt + env: + DOMAINS: ${{ vars.MONITORED_DOMAINS }} + + - name: Summary + if: always() + run: | + echo "### Site Health" >> $GITHUB_STEP_SUMMARY + echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY + + # ═══════════════════════════════════════════════════════════════════════ + # Issue Reporter β€” file issues for failed gates + # ═══════════════════════════════════════════════════════════════════════ + report-issues: + name: "Report Issues" + runs-on: ubuntu-latest + needs: [access_check, scripts_governance, repo_health] + if: >- + always() && + (needs.scripts_governance.result == 'failure' || + needs.repo_health.result == 'failure') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issues for failed gates" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + REPORTER="./automation/ci-issue-reporter.sh" + WF="Repo Health" + + report_gate() { + local gate="$1" result="$2" details="$3" + if [ "$result" = "failure" ]; then + "$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error + fi + } + + report_gate "Scripts Governance" \ + "${{ needs.scripts_governance.result }}" \ + "Scripts directory policy violations detected. Review required and allowed directories." + + report_gate "Repository Health" \ + "${{ needs.repo_health.result }}" \ + "Repository health checks failed β€” missing required artifacts, disallowed files, or content warnings. Check the CI run summary." diff --git a/.mokogitea/workflows/workflow-sync-trigger.yml b/.mokogitea/workflows/workflow-sync-trigger.yml new file mode 100644 index 0000000..371910c --- /dev/null +++ b/.mokogitea/workflows/workflow-sync-trigger.yml @@ -0,0 +1,73 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Universal +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml +# VERSION: 01.01.00 +# BRIEF: Trigger workflow sync to live repos when a PR is merged to main + +name: "Universal: Workflow Sync Trigger" + +on: + pull_request: + types: [closed] + branches: + - main + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + sync: + name: Sync workflows to live repos + runs-on: ubuntu-latest + if: >- + github.event.pull_request.merged == true && + !contains(github.event.pull_request.title, '[skip sync]') + + steps: + - name: Determine platform from repo name + id: platform + run: | + REPO="${{ github.event.repository.name }}" + case "$REPO" in + Template-Joomla) PLATFORM="joomla" ;; + Template-Dolibarr) PLATFORM="dolibarr" ;; + Template-Go) PLATFORM="go" ;; + Template-MCP) PLATFORM="mcp" ;; + Template-Generic) PLATFORM="" ;; + *) PLATFORM="" ;; + esac + echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" + echo "Platform: ${PLATFORM:-all}" + + - name: Clone mokocli + env: + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + run: | + GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}" + git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli + + - name: Install dependencies + run: | + cd /tmp/mokocli + composer install --no-dev --no-interaction --quiet 2>/dev/null || true + + - name: Run workflow sync + env: + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + run: | + ARGS="--token ${MOKOGITEA_TOKEN}" + ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}" + ARGS="${ARGS} --phase repos" + + PLATFORM="${{ steps.platform.outputs.platform }}" + if [ -n "$PLATFORM" ]; then + ARGS="${ARGS} --platform-filter ${PLATFORM}" + fi + + php /tmp/mokocli/cli/workflow_sync.php ${ARGS} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bae1973 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ + + +# Changelog + +## [Unreleased] + +### Added +- **Repository** β€” initial repo creation with dev branch, topics, workflows +- **Roadmap Issue** β€” implementation roadmap with full feature checklist diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9c2bdba --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,31 @@ +# MokoSuiteEvent + +Event management for Joomla 6 β€” ticketing, registration, check-in, capacity, venues, speakers, sponsors. + +## Quick Reference + +| Field | Value | +|---|---| +| **Package** | `pkg_mokosuiteevent` | +| **Layer** | 2 (requires: Client β†’ CRM) | +| **Language** | PHP 8.3+ | +| **Branch** | develop on `dev`, merge to `main` (protected) | +| **Wiki** | [MokoSuiteEvent Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteEvent/wiki) | + +## Architecture + +Joomla **package** β€” Layer 2 add-on. CRM contacts as attendees, deals for sponsorship. + +## Rules + +- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js` +- **Attribution**: `Authored-by: Moko Consulting` +- **Workflow directory**: `.mokogitea/` +- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoCLI/wiki) +- **Changelog**: `[Unreleased]` only β€” release system assigns versions + +## Coding Standards + +- PHP 8.3+ / Joomla 6 patterns +- `$this->getDatabase()` in models, `Factory::getContainer()->get(DatabaseInterface::class)` in helpers +- `Factory::getApplication()->getIdentity()` for user diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2ecc653 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,35 @@ + + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, colour, religion, or sexual +identity and orientation. + +## Our Standards + +Examples of behaviour that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Accepting constructive criticism gracefully +- Focusing on what is best for the community + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behaviour may be +reported by contacting the project team at +[info@mokoconsulting.tech](mailto:info@mokoconsulting.tech). + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e8697d2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ + + +# Contributing to Moko Consulting Projects + +Thank you for your interest in contributing! This guide explains our workflow, +conventions, and how to get your changes merged. + +## Branching Workflow + +We use a **stability-gated** branching model: + +``` +feature/* ──── PR ───→ dev + β”‚ RC cut β†’ rc β†’ main +fix/* ───────── PR β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +1. **Create a branch** from `dev`: + - `feature/` for new functionality + - `fix/` for bug fixes + - `chore/` for maintenance +2. **Open a PR** into `dev`. +3. **CI must pass** before merge. +4. **Release cuts**: `dev β†’ rc β†’ main` are handled by maintainers. + +> **Never commit directly to `main` or `dev`.** + +## Version Policy + +All repositories use the **XX.YY.ZZ** versioning scheme (two-digit segments): + +- `XX` -- major (breaking changes) +- `YY` -- minor (new features, backward-compatible) +- `ZZ` -- patch (bug fixes, security patches) + +**Stability suffixes** may be appended during pre-release: + +| Suffix | Meaning | Example | +|---|---|---| +| `-alpha.N` | Early testing | `06.01.00-alpha.1` | +| `-beta.N` | Feature complete | `06.01.00-beta.2` | +| `-rc.N` | Release candidate | `06.01.00-rc.1` | +| *(none)* | Stable release | `06.01.00` | + +## Auto-Bump + +Version bumps are **automated** via the `auto-bump` workflow: + +- Merges into `dev` trigger a minor/patch bump. +- The workflow updates all version references (manifests, changelog, etc.). +- **Do not manually edit version numbers** -- let the workflow handle it. + +## Commit Messages + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): + + + + +``` + +**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert` + +## Pull Request Checklist + +Before submitting a PR, ensure: + +- [ ] Branch is based on latest `dev` +- [ ] Commit messages follow conventional commits +- [ ] CHANGELOG.md updated under `[Unreleased]` +- [ ] No `TODO.md`, `.claude/`, `.mcp.json`, or minified files included +- [ ] Code follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards) +- [ ] All CI checks pass + +## Code of Conduct + +All contributors are expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Questions? + +Open a [Question issue](../../issues/new?template=question.md) or contact +us at [hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech). diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..aeb3e4b --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,49 @@ + + +# Governance + +## Project Leadership + +This repository is maintained by **Moko Consulting** under a **sole operator** model. + +- **Lead Maintainer**: Jonathan Miller (@jmiller) +- **Organisation**: [Moko Consulting](https://mokoconsulting.tech) + +## Decision Making + +- All architectural decisions are made by the lead maintainer. +- Community feedback is welcome via [RFC issues](../../issues/new?template=rfc.md). +- Breaking changes are documented via [ADR issues](../../issues/new?template=adr.md). + +## Contribution Policy + +- **All changes** must go through a pull request (PR). +- **CI checks** are mandatory before merge. +- **Direct push** to `main` and `dev` is restricted to automated workflows. + +## Code of Conduct + +All participants must adhere to our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Licensing + +All contributions are licensed under the same license as the project +(GPL-3.0-or-later unless otherwise stated in the repository root). + +## Security + +Security vulnerabilities should be reported privately. +See [SECURITY.md](SECURITY.md) for details. + +## Dispute Resolution + +Disputes are resolved by the lead maintainer. For escalation, +contact [info@mokoconsulting.tech](mailto:info@mokoconsulting.tech). + +## Changes to Governance + +This document may be updated at any time by the lead maintainer. +Significant changes will be announced via an RFC issue. diff --git a/README.md b/README.md index 61b7e85..2305294 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ -# MokoSuiteEvent +# MokoSuite Event -Event management for Joomla 6 β€” ticketing, registration, check-in, capacity, venues, speakers, sponsors \ No newline at end of file +Event management for Joomla 6 β€” ticketing, registration, check-in, venues, speakers, sponsors. + +**Layer 2** add-on for [MokoSuite](https://git.mokoconsulting.tech/MokoConsulting). Requires: Client β†’ CRM. + +## Links + +- [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteEvent/wiki) Β· [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteEvent/issues) Β· [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteEvent/releases) + +GPL-3.0-or-later Β· Copyright Β© 2026 [Moko Consulting](https://mokoconsulting.tech) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7f29d29 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,90 @@ + + +# Security Policy + +## Supported Versions + +| Version | Supported | +|---|---| +| Latest stable | βœ… Full support | +| Previous major | ⚠️ Critical fixes only | +| Older | ❌ No support | + +## Reporting a Vulnerability + +**Do not report security vulnerabilities via public issues.** + +Instead, please report them privately: + +1. **Email**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech) +2. **Subject**: `[SECURITY] - ` + +### What to Include + +- Description of the vulnerability +- Steps to reproduce +- Affected versions +- Potential impact +- Suggested fix (if any) + +## Severity Classification + +| Severity | Description | Response Time | +|---|---|---| +| **Critical** | Remote code execution, SQL injection, auth bypass | 24 hours | +| **High** | XSS, CSRF, privilege escalation | 48 hours | +| **Medium** | Information disclosure, path traversal | 72 hours | +| **Low** | Best practice violation, hardening suggestion | Next release | + +## Remediation Timeline + +1. **Acknowledgement**: Within 24 hours of report +2. **Assessment**: Within 72 hours +3. **Fix development**: Based on severity +4. **Release**: Patch release with security advisory +5. **Disclosure**: Coordinated disclosure after fix is available + +## Security Best Practices + +### For Contributors + +- Never commit secrets, credentials, or API keys +- Use parameterised queries (no raw SQL concatenation) +- Validate and sanitise all user input +- Follow Joomla API for access control checks +- Use Joomla's `HTMLHelper` for output escaping +- Include SPDX license headers in all source files + +### For Users + +- Keep Joomla and all extensions updated +- Use strong, unique passwords +- Enable two-factor authentication +- Review file permissions regularly +- Monitor Joomla error logs + +## Security Updates + +Security patches are delivered through the standard update channel. +Critical fixes may receive an emergency out-of-band release. + +## Responsible Disclosure + +We follow coordinated disclosure practices: + +- We will work with reporters to understand and reproduce the issue +- We will develop and test a fix +- We will credit reporters (with permission) in security advisories +- We ask that reporters allow reasonable time for a fix before public disclosure + +## Contact + +- **Security team**: [security@mokoconsulting.tech](mailto:security@mokoconsulting.tech) +- **General**: [hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech) + +--- + +Thank you for helping keep Moko Consulting projects secure. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d938818 --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "mokoconsulting/mokojoomgallery", + "description": "Joomla extension by Moko Consulting", + "type": "joomla-package", + "license": "GPL-3.0-or-later", + "homepage": "https://mokoconsulting.tech", + "authors": [ + { + "name": "Moko Consulting", + "email": "hello@mokoconsulting.tech" + } + ], + "require": { + "php": ">=8.1" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.0", + "phpstan/phpstan": "^2.0", + "joomla/coding-standards": "dev-main" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/packages/MokoSuiteCRM b/packages/MokoSuiteCRM new file mode 160000 index 0000000..b984e1a --- /dev/null +++ b/packages/MokoSuiteCRM @@ -0,0 +1 @@ +Subproject commit b984e1aad642fae946cfd8d7e3e8a75aa6f14df1 diff --git a/packages/MokoSuiteClient b/packages/MokoSuiteClient new file mode 160000 index 0000000..a8d1e8f --- /dev/null +++ b/packages/MokoSuiteClient @@ -0,0 +1 @@ +Subproject commit a8d1e8f276c88ea72a163a65631cfdeb43052e66 diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..d6e669c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,20 @@ +# Copyright (C) 2026 Moko Consulting +# SPDX-License-Identifier: GPL-3.0-or-later +# FILE INFORMATION +# DEFGROUP: Config.StaticAnalysis +# INGROUP: Development +# BRIEF: PHPStan configuration for Joomla extension static analysis + +parameters: + level: 5 + paths: + - src + scanDirectories: + # Joomla framework stubs (if available) + - vendor/joomla + ignoreErrors: + # Joomla service-container architecture: Factory/Container returns mixed + - '#Call to an undefined method Joomla\\CMS\\Application\\.*::get#i' + - '#Call to method .* on an unknown class Joomla\\Cms\\Extension\\.*#' + # Joomla MVC pattern: Table::getInstance returns Table|bool + - '#Method Joomla\\CMS\\Table\\Table::getInstance#' diff --git a/source/packages/com_mokosuiteevent/admin/access.xml b/source/packages/com_mokosuiteevent/admin/access.xml new file mode 100644 index 0000000..1de241f --- /dev/null +++ b/source/packages/com_mokosuiteevent/admin/access.xml @@ -0,0 +1,20 @@ + + +
+ + + + + + + + + + + + + + + +
+
diff --git a/source/packages/com_mokosuiteevent/admin/config.xml b/source/packages/com_mokosuiteevent/admin/config.xml new file mode 100644 index 0000000..bb9517c --- /dev/null +++ b/source/packages/com_mokosuiteevent/admin/config.xml @@ -0,0 +1,20 @@ + + +
+ + + +
+
+ + +
+
+ + + +
+
+ +
+
diff --git a/source/packages/com_mokosuiteevent/admin/services/provider.php b/source/packages/com_mokosuiteevent/admin/services/provider.php new file mode 100644 index 0000000..ae2e203 --- /dev/null +++ b/source/packages/com_mokosuiteevent/admin/services/provider.php @@ -0,0 +1,17 @@ +set(ComponentInterface::class, function (Container $container) { + $c = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class)); + $c->setMVCFactory($container->get(MVCFactoryInterface::class)); + return $c; + }); + } +}; diff --git a/source/packages/com_mokosuiteevent/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuiteevent/admin/src/Controller/DisplayController.php new file mode 100644 index 0000000..c587921 --- /dev/null +++ b/source/packages/com_mokosuiteevent/admin/src/Controller/DisplayController.php @@ -0,0 +1,7 @@ + 'ok']; } +} diff --git a/source/packages/com_mokosuiteevent/admin/src/View/EventDashboard/HtmlView.php b/source/packages/com_mokosuiteevent/admin/src/View/EventDashboard/HtmlView.php new file mode 100644 index 0000000..1e5caa5 --- /dev/null +++ b/source/packages/com_mokosuiteevent/admin/src/View/EventDashboard/HtmlView.php @@ -0,0 +1,11 @@ +

EventDashboard

Coming soon.

diff --git a/source/packages/com_mokosuiteevent/admin/tmpl/eventlist/default.php b/source/packages/com_mokosuiteevent/admin/tmpl/eventlist/default.php new file mode 100644 index 0000000..5fe78e7 --- /dev/null +++ b/source/packages/com_mokosuiteevent/admin/tmpl/eventlist/default.php @@ -0,0 +1 @@ +

EventList

Coming soon.

diff --git a/source/packages/com_mokosuiteevent/admin/tmpl/eventregistrations/default.php b/source/packages/com_mokosuiteevent/admin/tmpl/eventregistrations/default.php new file mode 100644 index 0000000..db8037f --- /dev/null +++ b/source/packages/com_mokosuiteevent/admin/tmpl/eventregistrations/default.php @@ -0,0 +1 @@ +

EventRegistrations

Coming soon.

diff --git a/source/packages/com_mokosuiteevent/admin/tmpl/eventspeakers/default.php b/source/packages/com_mokosuiteevent/admin/tmpl/eventspeakers/default.php new file mode 100644 index 0000000..1c6d579 --- /dev/null +++ b/source/packages/com_mokosuiteevent/admin/tmpl/eventspeakers/default.php @@ -0,0 +1 @@ +

EventSpeakers

Coming soon.

diff --git a/source/packages/com_mokosuiteevent/admin/tmpl/eventvenues/default.php b/source/packages/com_mokosuiteevent/admin/tmpl/eventvenues/default.php new file mode 100644 index 0000000..6f18569 --- /dev/null +++ b/source/packages/com_mokosuiteevent/admin/tmpl/eventvenues/default.php @@ -0,0 +1 @@ +

EventVenues

Coming soon.

diff --git a/source/packages/com_mokosuiteevent/mokosuiteevent.xml b/source/packages/com_mokosuiteevent/mokosuiteevent.xml new file mode 100644 index 0000000..351223d --- /dev/null +++ b/source/packages/com_mokosuiteevent/mokosuiteevent.xml @@ -0,0 +1,14 @@ + + + MokoSuite Event + Moko Consulting + 2026-06-23 + Copyright (C) 2026 Moko Consulting. + GPL-3.0-or-later + 01.00.19 + Moko\Component\MokoSuiteEvent + + servicessrctmpl + COM_MOKOSUITEEVENT + + diff --git a/source/packages/plg_system_mokosuiteevent/language/en-GB/plg_system_mokosuiteevent.ini b/source/packages/plg_system_mokosuiteevent/language/en-GB/plg_system_mokosuiteevent.ini new file mode 100644 index 0000000..f7f51b1 --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/language/en-GB/plg_system_mokosuiteevent.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITEEVENT="System - MokoSuite Event" +PLG_SYSTEM_MOKOSUITEEVENT_DESC="Event management β€” ticketing, registration, check-in, venues, speakers, sponsors." diff --git a/source/packages/plg_system_mokosuiteevent/language/en-GB/plg_system_mokosuiteevent.sys.ini b/source/packages/plg_system_mokosuiteevent/language/en-GB/plg_system_mokosuiteevent.sys.ini new file mode 100644 index 0000000..f7f51b1 --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/language/en-GB/plg_system_mokosuiteevent.sys.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITEEVENT="System - MokoSuite Event" +PLG_SYSTEM_MOKOSUITEEVENT_DESC="Event management β€” ticketing, registration, check-in, venues, speakers, sponsors." diff --git a/source/packages/plg_system_mokosuiteevent/mokosuiteevent.xml b/source/packages/plg_system_mokosuiteevent/mokosuiteevent.xml new file mode 100644 index 0000000..8123597 --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/mokosuiteevent.xml @@ -0,0 +1,42 @@ + + + System - MokoSuite Event + mokosuiteevent + Moko Consulting + 2026-06-23 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 01.00.19 + 8.3 + PLG_SYSTEM_MOKOSUITEEVENT_DESC + Moko\Plugin\System\MokoSuiteEvent + + src + services + language + sql + + + en-GB/plg_system_mokosuiteevent.ini + en-GB/plg_system_mokosuiteevent.sys.ini + + sql/install.mysql.sql + sql/uninstall.mysql.sql + + +
+ + + + + + + + + +
+
+
+
diff --git a/source/packages/plg_system_mokosuiteevent/services/provider.php b/source/packages/plg_system_mokosuiteevent/services/provider.php new file mode 100644 index 0000000..203bcf5 --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/services/provider.php @@ -0,0 +1,27 @@ +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new Event($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuiteevent')); + $plugin->setApplication(Factory::getApplication()); + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_system_mokosuiteevent/sql/install.mysql.sql b/source/packages/plg_system_mokosuiteevent/sql/install.mysql.sql new file mode 100644 index 0000000..4194e82 --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/sql/install.mysql.sql @@ -0,0 +1,151 @@ +-- +-- MokoSuite Event Tables +-- + +CREATE TABLE IF NOT EXISTS `#__mokosuiteevent_events` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `venue_id` INT UNSIGNED DEFAULT NULL, + `start_date` DATETIME NOT NULL, + `end_date` DATETIME NOT NULL, + `timezone` VARCHAR(50) NOT NULL DEFAULT 'UTC', + `capacity` INT UNSIGNED NOT NULL DEFAULT 0, + `registered_count` INT UNSIGNED NOT NULL DEFAULT 0, + `status` ENUM('draft','published','cancelled','completed') NOT NULL DEFAULT 'draft', + `is_recurring` TINYINT NOT NULL DEFAULT 0, + `recurrence_rule` VARCHAR(255) NOT NULL DEFAULT '', + `cover_image` VARCHAR(500) NOT NULL DEFAULT '', + `organizer_contact_id` INT DEFAULT NULL, + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_venue` (`venue_id`), + KEY `idx_dates` (`start_date`, `end_date`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteevent_ticket_types` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(255) NOT NULL, + `description` TEXT, + `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `quantity` INT UNSIGNED NOT NULL DEFAULT 0, + `sold` INT UNSIGNED NOT NULL DEFAULT 0, + `max_per_order` INT UNSIGNED NOT NULL DEFAULT 10, + `sale_start` DATETIME DEFAULT NULL, + `sale_end` DATETIME DEFAULT NULL, + `published` TINYINT NOT NULL DEFAULT 1, + `ordering` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_event` (`event_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteevent_registrations` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_id` INT UNSIGNED NOT NULL, + `contact_id` INT DEFAULT NULL, + `name` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `phone` VARCHAR(50) NOT NULL DEFAULT '', + `status` ENUM('pending','confirmed','cancelled','waitlisted','checked_in') NOT NULL DEFAULT 'pending', + `total_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `payment_status` ENUM('unpaid','paid','refunded') NOT NULL DEFAULT 'unpaid', + `notes` TEXT, + `registered_at` DATETIME NOT NULL, + `checked_in_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_event` (`event_id`), + KEY `idx_contact` (`contact_id`), + KEY `idx_status` (`status`), + KEY `idx_email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteevent_tickets` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `registration_id` INT UNSIGNED NOT NULL, + `ticket_type_id` INT UNSIGNED NOT NULL, + `qr_code` VARCHAR(100) NOT NULL DEFAULT '', + `attendee_name` VARCHAR(255) NOT NULL DEFAULT '', + `checked_in` TINYINT NOT NULL DEFAULT 0, + `checked_in_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_qr` (`qr_code`), + KEY `idx_registration` (`registration_id`), + KEY `idx_type` (`ticket_type_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteevent_venues` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `address` TEXT, + `city` VARCHAR(100) NOT NULL DEFAULT '', + `state` VARCHAR(50) NOT NULL DEFAULT '', + `postal_code` VARCHAR(20) NOT NULL DEFAULT '', + `capacity` INT UNSIGNED NOT NULL DEFAULT 0, + `amenities` TEXT, + `contact_name` VARCHAR(255) NOT NULL DEFAULT '', + `contact_phone` VARCHAR(50) NOT NULL DEFAULT '', + `contact_email` VARCHAR(255) NOT NULL DEFAULT '', + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteevent_speakers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `contact_id` INT DEFAULT NULL, + `name` VARCHAR(255) NOT NULL, + `title` VARCHAR(255) NOT NULL DEFAULT '', + `company` VARCHAR(255) NOT NULL DEFAULT '', + `bio` TEXT, + `photo` VARCHAR(500) NOT NULL DEFAULT '', + `website` VARCHAR(500) NOT NULL DEFAULT '', + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_contact` (`contact_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteevent_sponsors` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_id` INT UNSIGNED NOT NULL, + `contact_id` INT DEFAULT NULL, + `name` VARCHAR(255) NOT NULL, + `tier` ENUM('platinum','gold','silver','bronze','community') NOT NULL DEFAULT 'community', + `logo` VARCHAR(500) NOT NULL DEFAULT '', + `website` VARCHAR(500) NOT NULL DEFAULT '', + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `benefits` TEXT, + `published` TINYINT NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + KEY `idx_event` (`event_id`), + KEY `idx_tier` (`tier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteevent_sessions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_id` INT UNSIGNED NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `room` VARCHAR(100) NOT NULL DEFAULT '', + `track` VARCHAR(100) NOT NULL DEFAULT '', + `start_time` DATETIME NOT NULL, + `end_time` DATETIME NOT NULL, + `capacity` INT UNSIGNED NOT NULL DEFAULT 0, + `ordering` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_event` (`event_id`), + KEY `idx_time` (`start_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuiteevent_session_speakers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `session_id` INT UNSIGNED NOT NULL, + `speaker_id` INT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_session_speaker` (`session_id`, `speaker_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/plg_system_mokosuiteevent/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuiteevent/sql/uninstall.mysql.sql new file mode 100644 index 0000000..52ca541 --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/sql/uninstall.mysql.sql @@ -0,0 +1,13 @@ +-- +-- MokoSuite Event β€” Uninstall +-- + +DROP TABLE IF EXISTS `#__mokosuiteevent_session_speakers`; +DROP TABLE IF EXISTS `#__mokosuiteevent_sessions`; +DROP TABLE IF EXISTS `#__mokosuiteevent_sponsors`; +DROP TABLE IF EXISTS `#__mokosuiteevent_speakers`; +DROP TABLE IF EXISTS `#__mokosuiteevent_venues`; +DROP TABLE IF EXISTS `#__mokosuiteevent_tickets`; +DROP TABLE IF EXISTS `#__mokosuiteevent_registrations`; +DROP TABLE IF EXISTS `#__mokosuiteevent_ticket_types`; +DROP TABLE IF EXISTS `#__mokosuiteevent_events`; diff --git a/source/packages/plg_system_mokosuiteevent/src/Extension/Event.php b/source/packages/plg_system_mokosuiteevent/src/Extension/Event.php new file mode 100644 index 0000000..ec43d4e --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/src/Extension/Event.php @@ -0,0 +1,18 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('t') . '.*', + $db->quoteName('r.name', 'registrant_name'), + $db->quoteName('r.email', 'registrant_email'), + $db->quoteName('r.event_id'), + $db->quoteName('tt.name', 'ticket_type_name'), + $db->quoteName('e.title', 'event_title'), + ]) + ->from($db->quoteName('#__mokosuiteevent_tickets', 't')) + ->join('INNER', $db->quoteName('#__mokosuiteevent_registrations', 'r') + . ' ON ' . $db->quoteName('r.id') . ' = ' . $db->quoteName('t.registration_id')) + ->join('INNER', $db->quoteName('#__mokosuiteevent_ticket_types', 'tt') + . ' ON ' . $db->quoteName('tt.id') . ' = ' . $db->quoteName('t.ticket_type_id')) + ->join('INNER', $db->quoteName('#__mokosuiteevent_events', 'e') + . ' ON ' . $db->quoteName('e.id') . ' = ' . $db->quoteName('r.event_id')) + ->where($db->quoteName('t.qr_code') . ' = ' . $db->quote($qrData)); + + $db->setQuery($query); + $ticket = $db->loadObject(); + + if (!$ticket) { + throw new \RuntimeException('Invalid QR code'); + } + + if ((int) $ticket->checked_in === 1) { + throw new \RuntimeException('Ticket already checked in at ' . $ticket->checked_in_at); + } + + $now = Factory::getDate()->toSql(); + + // Mark ticket checked in + $db->setQuery($db->getQuery(true) + ->update($db->quoteName('#__mokosuiteevent_tickets')) + ->set($db->quoteName('checked_in') . ' = 1') + ->set($db->quoteName('checked_in_at') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . (int) $ticket->id)); + $db->execute(); + + // Update registration status + $db->setQuery($db->getQuery(true) + ->update($db->quoteName('#__mokosuiteevent_registrations')) + ->set($db->quoteName('status') . ' = ' . $db->quote('checked_in')) + ->set($db->quoteName('checked_in_at') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . (int) $ticket->registration_id)); + $db->execute(); + + return (object) [ + 'ticket_id' => (int) $ticket->id, + 'attendee' => $ticket->attendee_name ?: $ticket->registrant_name, + 'email' => $ticket->registrant_email, + 'event' => $ticket->event_title, + 'ticket_type' => $ticket->ticket_type_name, + 'checked_in_at' => $now, + ]; + } + + /** + * Get attendance count for an event. + */ + public static function getAttendanceCount(int $eventId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Checked in (via tickets) + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuiteevent_tickets', 't')) + ->join('INNER', $db->quoteName('#__mokosuiteevent_registrations', 'r') + . ' ON ' . $db->quoteName('r.id') . ' = ' . $db->quoteName('t.registration_id')) + ->where($db->quoteName('r.event_id') . ' = ' . (int) $eventId) + ->where($db->quoteName('t.checked_in') . ' = 1')); + $checkedIn = (int) $db->loadResult(); + + // Total registered (non-cancelled) + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuiteevent_registrations')) + ->where($db->quoteName('event_id') . ' = ' . (int) $eventId) + ->where($db->quoteName('status') . ' != ' . $db->quote('cancelled'))); + $registered = (int) $db->loadResult(); + + // Event capacity + $db->setQuery($db->getQuery(true) + ->select($db->quoteName('capacity')) + ->from($db->quoteName('#__mokosuiteevent_events')) + ->where($db->quoteName('id') . ' = ' . (int) $eventId)); + $capacity = (int) $db->loadResult(); + + return (object) [ + 'checked_in' => $checkedIn, + 'registered' => $registered, + 'capacity' => $capacity, + ]; + } + + /** + * Get registrants who have not checked in (late arrivals). + */ + public static function getLateArrivals(int $eventId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('r.id', 'registration_id'), + $db->quoteName('r.name'), + $db->quoteName('r.email'), + $db->quoteName('r.phone'), + $db->quoteName('r.registered_at'), + ]) + ->from($db->quoteName('#__mokosuiteevent_registrations', 'r')) + ->where($db->quoteName('r.event_id') . ' = ' . (int) $eventId) + ->where($db->quoteName('r.status') . ' IN (' . $db->quote('pending') . ', ' . $db->quote('confirmed') . ')') + ->order($db->quoteName('r.name') . ' ASC'); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuiteevent/src/Helper/EventHelper.php b/source/packages/plg_system_mokosuiteevent/src/Helper/EventHelper.php new file mode 100644 index 0000000..f7dc6f6 --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/src/Helper/EventHelper.php @@ -0,0 +1,186 @@ +get(DatabaseInterface::class); + + $required = ['title', 'start_date', 'end_date']; + + foreach ($required as $field) { + if (empty($data[$field])) { + throw new \InvalidArgumentException('Missing required field: ' . $field); + } + } + + if (strtotime($data['end_date']) <= strtotime($data['start_date'])) { + throw new \InvalidArgumentException('End date must be after start date'); + } + + $user = Factory::getApplication()->getIdentity(); + + $event = (object) [ + 'title' => $data['title'], + 'description' => $data['description'] ?? '', + 'venue_id' => isset($data['venue_id']) ? (int) $data['venue_id'] : null, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'timezone' => $data['timezone'] ?? 'UTC', + 'capacity' => (int) ($data['capacity'] ?? 0), + 'registered_count' => 0, + 'status' => $data['status'] ?? 'draft', + 'is_recurring' => (int) ($data['is_recurring'] ?? 0), + 'recurrence_rule' => $data['recurrence_rule'] ?? '', + 'cover_image' => $data['cover_image'] ?? '', + 'organizer_contact_id' => isset($data['organizer_contact_id']) ? (int) $data['organizer_contact_id'] : null, + 'published' => (int) ($data['published'] ?? 1), + 'created' => Factory::getDate()->toSql(), + 'created_by' => $user ? (int) $user->id : 0, + ]; + + $db->insertObject('#__mokosuiteevent_events', $event, 'id'); + + return $event; + } + + /** + * Get upcoming events in the next N days with registration counts. + */ + public static function getUpcoming(int $days = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $end = Factory::getDate('+' . $days . ' days')->toSql(); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('e') . '.*', + 'COUNT(' . $db->quoteName('r.id') . ') AS ' . $db->quoteName('registration_count'), + ]) + ->from($db->quoteName('#__mokosuiteevent_events', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuiteevent_registrations', 'r') + . ' ON ' . $db->quoteName('r.event_id') . ' = ' . $db->quoteName('e.id') + . ' AND ' . $db->quoteName('r.status') . ' != ' . $db->quote('cancelled')) + ->where($db->quoteName('e.start_date') . ' >= ' . $db->quote($now)) + ->where($db->quoteName('e.start_date') . ' <= ' . $db->quote($end)) + ->where($db->quoteName('e.published') . ' = 1') + ->where($db->quoteName('e.status') . ' != ' . $db->quote('cancelled')) + ->group($db->quoteName('e.id')) + ->order($db->quoteName('e.start_date') . ' ASC'); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Get dashboard statistics. + */ + public static function getDashboard(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Upcoming count + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuiteevent_events')) + ->where($db->quoteName('start_date') . ' >= ' . $db->quote($now)) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('status') . ' != ' . $db->quote('cancelled'))); + $upcoming = (int) $db->loadResult(); + + // Total registrations (non-cancelled) + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuiteevent_registrations')) + ->where($db->quoteName('status') . ' != ' . $db->quote('cancelled'))); + $registrations = (int) $db->loadResult(); + + // Total revenue + $db->setQuery($db->getQuery(true) + ->select('COALESCE(SUM(' . $db->quoteName('total_amount') . '), 0)') + ->from($db->quoteName('#__mokosuiteevent_registrations')) + ->where($db->quoteName('payment_status') . ' = ' . $db->quote('paid'))); + $revenue = (float) $db->loadResult(); + + // Sold out events (capacity > 0 and registered_count >= capacity) + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuiteevent_events')) + ->where($db->quoteName('capacity') . ' > 0') + ->where($db->quoteName('registered_count') . ' >= ' . $db->quoteName('capacity')) + ->where($db->quoteName('start_date') . ' >= ' . $db->quote($now)) + ->where($db->quoteName('published') . ' = 1')); + $soldOut = (int) $db->loadResult(); + + return (object) [ + 'upcoming' => $upcoming, + 'registrations' => $registrations, + 'revenue' => $revenue, + 'sold_out' => $soldOut, + ]; + } + + /** + * Get a single event with ticket types, venue, and speaker count. + */ + public static function getById(int $eventId): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Event with venue + $query = $db->getQuery(true) + ->select([ + $db->quoteName('e') . '.*', + $db->quoteName('v.name', 'venue_name'), + $db->quoteName('v.address', 'venue_address'), + $db->quoteName('v.city', 'venue_city'), + $db->quoteName('v.capacity', 'venue_capacity'), + ]) + ->from($db->quoteName('#__mokosuiteevent_events', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuiteevent_venues', 'v') + . ' ON ' . $db->quoteName('v.id') . ' = ' . $db->quoteName('e.venue_id')) + ->where($db->quoteName('e.id') . ' = ' . (int) $eventId); + + $db->setQuery($query); + $event = $db->loadObject(); + + if (!$event) { + return null; + } + + // Ticket types + $db->setQuery($db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteevent_ticket_types')) + ->where($db->quoteName('event_id') . ' = ' . (int) $eventId) + ->order($db->quoteName('ordering') . ' ASC')); + $event->ticket_types = $db->loadObjectList() ?: []; + + // Speaker count via sessions + $db->setQuery($db->getQuery(true) + ->select('COUNT(DISTINCT ' . $db->quoteName('ss.speaker_id') . ')') + ->from($db->quoteName('#__mokosuiteevent_sessions', 's')) + ->join('INNER', $db->quoteName('#__mokosuiteevent_session_speakers', 'ss') + . ' ON ' . $db->quoteName('ss.session_id') . ' = ' . $db->quoteName('s.id')) + ->where($db->quoteName('s.event_id') . ' = ' . (int) $eventId)); + $event->speaker_count = (int) $db->loadResult(); + + return $event; + } +} diff --git a/source/packages/plg_system_mokosuiteevent/src/Helper/SpeakerHelper.php b/source/packages/plg_system_mokosuiteevent/src/Helper/SpeakerHelper.php new file mode 100644 index 0000000..d9e6cef --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/src/Helper/SpeakerHelper.php @@ -0,0 +1,73 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select([ + 'DISTINCT ' . $db->quoteName('sp.id'), + $db->quoteName('sp.name'), + $db->quoteName('sp.title'), + $db->quoteName('sp.company'), + $db->quoteName('sp.bio'), + $db->quoteName('sp.photo'), + $db->quoteName('sp.website'), + ]) + ->from($db->quoteName('#__mokosuiteevent_speakers', 'sp')) + ->join('INNER', $db->quoteName('#__mokosuiteevent_session_speakers', 'ss') + . ' ON ' . $db->quoteName('ss.speaker_id') . ' = ' . $db->quoteName('sp.id')) + ->join('INNER', $db->quoteName('#__mokosuiteevent_sessions', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('ss.session_id')) + ->where($db->quoteName('s.event_id') . ' = ' . (int) $eventId) + ->where($db->quoteName('sp.published') . ' = 1') + ->order($db->quoteName('sp.name') . ' ASC'); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Assign a speaker to a session. + */ + public static function assignToSession(int $speakerId, int $sessionId): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Check if already assigned + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuiteevent_session_speakers')) + ->where($db->quoteName('speaker_id') . ' = ' . (int) $speakerId) + ->where($db->quoteName('session_id') . ' = ' . (int) $sessionId)); + + if ((int) $db->loadResult() > 0) { + return true; + } + + $link = (object) [ + 'session_id' => (int) $sessionId, + 'speaker_id' => (int) $speakerId, + ]; + + $db->insertObject('#__mokosuiteevent_session_speakers', $link); + + return true; + } +} diff --git a/source/packages/plg_system_mokosuiteevent/src/Helper/SponsorHelper.php b/source/packages/plg_system_mokosuiteevent/src/Helper/SponsorHelper.php new file mode 100644 index 0000000..0d4fb23 --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/src/Helper/SponsorHelper.php @@ -0,0 +1,90 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('id'), + $db->quoteName('name'), + $db->quoteName('tier'), + $db->quoteName('logo'), + $db->quoteName('website'), + $db->quoteName('amount'), + $db->quoteName('benefits'), + ]) + ->from($db->quoteName('#__mokosuiteevent_sponsors')) + ->where($db->quoteName('event_id') . ' = ' . (int) $eventId) + ->where($db->quoteName('published') . ' = 1') + ->order('FIELD(' . $db->quoteName('tier') + . ', ' . $db->quote('platinum') + . ', ' . $db->quote('gold') + . ', ' . $db->quote('silver') + . ', ' . $db->quote('bronze') + . ', ' . $db->quote('community') . ')') + ->order($db->quoteName('name') . ' ASC'); + + $db->setQuery($query); + $sponsors = $db->loadObjectList() ?: []; + + $grouped = []; + + foreach ($sponsors as $sponsor) { + $grouped[$sponsor->tier][] = $sponsor; + } + + return $grouped; + } + + /** + * Track a sponsor benefit status. + * + * Updates the benefits JSON field on the sponsor row. + */ + public static function trackBenefit(int $sponsorId, string $benefit, string $status): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Load current benefits + $db->setQuery($db->getQuery(true) + ->select($db->quoteName('benefits')) + ->from($db->quoteName('#__mokosuiteevent_sponsors')) + ->where($db->quoteName('id') . ' = ' . (int) $sponsorId)); + $raw = $db->loadResult(); + + $benefits = $raw ? json_decode($raw, true) : []; + + if (!is_array($benefits)) { + $benefits = []; + } + + $benefits[$benefit] = [ + 'status' => $status, + 'updated_at' => Factory::getDate()->toSql(), + ]; + + $db->setQuery($db->getQuery(true) + ->update($db->quoteName('#__mokosuiteevent_sponsors')) + ->set($db->quoteName('benefits') . ' = ' . $db->quote(json_encode($benefits))) + ->where($db->quoteName('id') . ' = ' . (int) $sponsorId)); + $db->execute(); + + return true; + } +} diff --git a/source/packages/plg_system_mokosuiteevent/src/Helper/TicketHelper.php b/source/packages/plg_system_mokosuiteevent/src/Helper/TicketHelper.php new file mode 100644 index 0000000..eb7577e --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/src/Helper/TicketHelper.php @@ -0,0 +1,171 @@ +get(DatabaseInterface::class); + + // Get next ordering + $db->setQuery($db->getQuery(true) + ->select('COALESCE(MAX(' . $db->quoteName('ordering') . '), 0) + 1') + ->from($db->quoteName('#__mokosuiteevent_ticket_types')) + ->where($db->quoteName('event_id') . ' = ' . (int) $eventId)); + $ordering = (int) $db->loadResult(); + + $type = (object) [ + 'event_id' => (int) $eventId, + 'name' => $name, + 'price' => $price, + 'quantity' => (int) $quantity, + 'sold' => 0, + 'published' => 1, + 'ordering' => $ordering, + ]; + + $db->insertObject('#__mokosuiteevent_ticket_types', $type, 'id'); + + return $type; + } + + /** + * Purchase tickets with row-level locking to prevent oversell. + */ + public static function purchase(int $ticketTypeId, int $contactId, int $qty, string $attendeeName = ''): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Lock the ticket type row to prevent oversell + $db->setQuery( + 'SELECT * FROM ' . $db->quoteName('#__mokosuiteevent_ticket_types') + . ' WHERE ' . $db->quoteName('id') . ' = ' . (int) $ticketTypeId + . ' FOR UPDATE' + ); + $type = $db->loadObject(); + + if (!$type) { + throw new \RuntimeException('Ticket type not found'); + } + + $remaining = (int) $type->quantity - (int) $type->sold; + + if ($qty > $remaining) { + throw new \RuntimeException('Insufficient tickets: ' . $remaining . ' remaining'); + } + + if ($qty > (int) $type->max_per_order) { + throw new \RuntimeException('Maximum ' . $type->max_per_order . ' tickets per order'); + } + + $now = Factory::getDate()->toSql(); + $totalPrice = $type->price * $qty; + + // Create registration + $registration = (object) [ + 'event_id' => (int) $type->event_id, + 'contact_id' => (int) $contactId, + 'name' => $attendeeName, + 'email' => '', + 'status' => 'confirmed', + 'total_amount' => $totalPrice, + 'payment_status' => $totalPrice > 0 ? 'unpaid' : 'paid', + 'registered_at' => $now, + ]; + + $db->insertObject('#__mokosuiteevent_registrations', $registration, 'id'); + + // Create individual tickets + $tickets = []; + + for ($i = 0; $i < $qty; $i++) { + $ticket = (object) [ + 'registration_id' => (int) $registration->id, + 'ticket_type_id' => (int) $ticketTypeId, + 'qr_code' => self::generateQR(0), + 'attendee_name' => $attendeeName, + 'checked_in' => 0, + ]; + + $db->insertObject('#__mokosuiteevent_tickets', $ticket, 'id'); + // Re-generate QR with actual ticket ID + $ticket->qr_code = self::generateQR((int) $ticket->id); + + $db->setQuery($db->getQuery(true) + ->update($db->quoteName('#__mokosuiteevent_tickets')) + ->set($db->quoteName('qr_code') . ' = ' . $db->quote($ticket->qr_code)) + ->where($db->quoteName('id') . ' = ' . (int) $ticket->id)); + $db->execute(); + + $tickets[] = $ticket; + } + + // Update sold count + $db->setQuery($db->getQuery(true) + ->update($db->quoteName('#__mokosuiteevent_ticket_types')) + ->set($db->quoteName('sold') . ' = ' . $db->quoteName('sold') . ' + ' . (int) $qty) + ->where($db->quoteName('id') . ' = ' . (int) $ticketTypeId)); + $db->execute(); + + // Update event registered_count + $db->setQuery($db->getQuery(true) + ->update($db->quoteName('#__mokosuiteevent_events')) + ->set($db->quoteName('registered_count') . ' = ' . $db->quoteName('registered_count') . ' + ' . (int) $qty) + ->where($db->quoteName('id') . ' = ' . (int) $type->event_id)); + $db->execute(); + + return (object) [ + 'registration' => $registration, + 'tickets' => $tickets, + 'total' => $totalPrice, + ]; + } + + /** + * Generate a unique QR code string for check-in. + */ + public static function generateQR(int $ticketId): string + { + return 'MOKO-EVT-' . strtoupper(bin2hex(random_bytes(4))) + . '-' . str_pad((string) $ticketId, 6, '0', STR_PAD_LEFT) + . '-' . strtoupper(bin2hex(random_bytes(4))); + } + + /** + * Get remaining ticket availability by type for an event. + */ + public static function getAvailability(int $eventId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('id'), + $db->quoteName('name'), + $db->quoteName('price'), + $db->quoteName('quantity'), + $db->quoteName('sold'), + '(' . $db->quoteName('quantity') . ' - ' . $db->quoteName('sold') . ') AS ' . $db->quoteName('remaining'), + ]) + ->from($db->quoteName('#__mokosuiteevent_ticket_types')) + ->where($db->quoteName('event_id') . ' = ' . (int) $eventId) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuiteevent/src/Helper/VenueHelper.php b/source/packages/plg_system_mokosuiteevent/src/Helper/VenueHelper.php new file mode 100644 index 0000000..3a3b91c --- /dev/null +++ b/source/packages/plg_system_mokosuiteevent/src/Helper/VenueHelper.php @@ -0,0 +1,64 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('id'), + $db->quoteName('name'), + $db->quoteName('address'), + $db->quoteName('city'), + $db->quoteName('state'), + $db->quoteName('postal_code'), + $db->quoteName('capacity'), + $db->quoteName('contact_name'), + $db->quoteName('contact_phone'), + $db->quoteName('contact_email'), + ]) + ->from($db->quoteName('#__mokosuiteevent_venues')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('name') . ' ASC'); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Check if a venue is free on a given date. + */ + public static function checkAvailability(int $venueId, string $date): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuiteevent_events')) + ->where($db->quoteName('venue_id') . ' = ' . (int) $venueId) + ->where('DATE(' . $db->quoteName('start_date') . ') <= ' . $db->quote($date)) + ->where('DATE(' . $db->quoteName('end_date') . ') >= ' . $db->quote($date)) + ->where($db->quoteName('status') . ' != ' . $db->quote('cancelled')) + ->where($db->quoteName('published') . ' = 1'); + + $db->setQuery($query); + + return (int) $db->loadResult() === 0; + } +} diff --git a/source/packages/plg_webservices_mokosuiteevent/mokosuiteevent.xml b/source/packages/plg_webservices_mokosuiteevent/mokosuiteevent.xml new file mode 100644 index 0000000..686798f --- /dev/null +++ b/source/packages/plg_webservices_mokosuiteevent/mokosuiteevent.xml @@ -0,0 +1,10 @@ + + + Web Services - MokoSuite Event + mokosuiteevent + Moko Consulting + 01.00.19 + GPL-3.0-or-later + Moko\Plugin\WebServices\MokoSuiteEvent + srcservices + diff --git a/source/packages/plg_webservices_mokosuiteevent/services/provider.php b/source/packages/plg_webservices_mokosuiteevent/services/provider.php new file mode 100644 index 0000000..f2793ef --- /dev/null +++ b/source/packages/plg_webservices_mokosuiteevent/services/provider.php @@ -0,0 +1,15 @@ +set(PluginInterface::class, function (Container $container) { + return new MokoSuiteEvent($container->get(DispatcherInterface::class), (array) PluginHelper::getPlugin('webservices', 'mokosuiteevent')); + }); + } +}; diff --git a/source/packages/plg_webservices_mokosuiteevent/src/Extension/MokoSuiteEvent.php b/source/packages/plg_webservices_mokosuiteevent/src/Extension/MokoSuiteEvent.php new file mode 100644 index 0000000..9f44c53 --- /dev/null +++ b/source/packages/plg_webservices_mokosuiteevent/src/Extension/MokoSuiteEvent.php @@ -0,0 +1,19 @@ + 'onBeforeApiRoute']; + } + public function onBeforeApiRoute(&$event): void { + $router = $event->getArgument('router'); + $router->createCRUDRoutes('v1/mokosuiteevent/events', 'events', ['component' => 'com_mokosuiteevent']); + $router->createCRUDRoutes('v1/mokosuiteevent/tickets', 'tickets', ['component' => 'com_mokosuiteevent']); + $router->createCRUDRoutes('v1/mokosuiteevent/registrations', 'registrations', ['component' => 'com_mokosuiteevent']); + $router->createCRUDRoutes('v1/mokosuiteevent/venues', 'venues', ['component' => 'com_mokosuiteevent']); + $router->createCRUDRoutes('v1/mokosuiteevent/speakers', 'speakers', ['component' => 'com_mokosuiteevent']); + $router->createCRUDRoutes('v1/mokosuiteevent/sponsors', 'sponsors', ['component' => 'com_mokosuiteevent']); + } +} diff --git a/source/pkg_mokosuiteevent.xml b/source/pkg_mokosuiteevent.xml new file mode 100644 index 0000000..04cce7a --- /dev/null +++ b/source/pkg_mokosuiteevent.xml @@ -0,0 +1,23 @@ + + + Package - MokoSuite Event + mokosuiteevent + 01.00.19 + 2026-06-23 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + Event management β€” ticketing, registration, check-in, venues, speakers + 8.3 + + true + script.php + + plg_system_mokosuiteevent.zip + + + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteEvent/updates.xml + + diff --git a/source/script.php b/source/script.php new file mode 100644 index 0000000..bd6e1ac --- /dev/null +++ b/source/script.php @@ -0,0 +1,71 @@ +warnMissingLicenseKey(); + } + + private function warnMissingLicenseKey(): void + { + try + { + $db = Factory::getDbo(); + $app = Factory::getApplication(); + + $query = $db->getQuery(true) + ->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')]) + ->from($db->quoteName('#__update_sites')) + ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteEvent%') + . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteEvent%') . ')') + ->setLimit(1); + $db->setQuery($query); + $site = $db->loadObject(); + + if ($site) + { + $extraQuery = (string) ($site->extra_query ?? ''); + + if (!empty($extraQuery) && strpos($extraQuery, 'dlid=') !== false) + { + parse_str($extraQuery, $parsed); + + if (!empty($parsed['dlid'])) + { + return; + } + } + + $editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id; + } + else + { + $editUrl = 'index.php?option=com_installer&view=updatesites'; + } + + $app->enqueueMessage( + 'Moko Consulting License Key Required β€” ' + . 'No download key is configured. Updates will not be available until a valid license key is entered. ' + . 'Enter License Key', + 'warning' + ); + } + catch (\Throwable $e) + { + // Silent β€” avoid breaking install if update_sites query fails + } + } +}