249 Commits

Author SHA1 Message Date
jmiller 173e2c4c35 chore: sync security-audit.yml from Template-Generic [skip ci] 2026-06-21 06:34:41 +00:00
jmiller 798094bab9 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-21 06:34:40 +00:00
jmiller 665a4fec63 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 06:34:39 +00:00
jmiller eda5875718 chore: sync notify.yml from Template-Generic [skip ci] 2026-06-21 06:34:38 +00:00
jmiller cd02979860 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 06:34:37 +00:00
jmiller 62428b1c9a chore: sync gitleaks.yml from Template-Generic [skip ci] 2026-06-21 06:34:36 +00:00
jmiller 47be9257d3 chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-21 06:34:34 +00:00
jmiller f12347594b chore: sync cleanup.yml from Template-Generic [skip ci] 2026-06-21 06:34:33 +00:00
jmiller a4c1364eb2 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-21 06:34:32 +00:00
jmiller 1cd3aff4ce chore: sync branch-cleanup.yml from Template-Generic [skip ci] 2026-06-21 06:34:31 +00:00
jmiller fccc9313e3 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-21 06:34:30 +00:00
gitea-actions[bot] f19fb7fb0d chore: promote changelog [Unreleased] → [09.38.00] 2026-06-21 06:19:19 +00:00
gitea-actions[bot] 733de92d19 chore(release): build 09.38.00 [skip ci]
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokocli CI / CI Summary (push) Blocked by required conditions
Publish to Composer / Publish Package (release) Successful in 34s
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 42s
2026-06-21 06:19:16 +00:00
jmiller fa0862b224 Merge pull request 'fix(package): only zip sub-extensions listed in package manifest (#300)' (#305) from dev into main
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokocli CI / CI Summary (push) Blocked by required conditions
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 49s
2026-06-21 06:18:59 +00:00
gitea-actions[bot] 193fa8be53 chore(version): pre-release bump to 09.37.07-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 23s
Publish to Composer / Publish Package (release) Successful in 41s
2026-06-21 06:18:56 +00:00
jmiller 8a5209bf19 Merge pull request 'chore: complete namespace cleanup across all files' (#306) from fix/namespace-cleanup into dev
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokocli CI / CI Summary (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokocli CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Sync Feature Branch Versions / Sync feature branches with dev (push) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 15s
Platform: mokocli CI / Gate 1: Code Quality (pull_request) Failing after 55s
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 57s
2026-06-21 06:18:37 +00:00
Jonathan Miller 95880d3e44 chore: complete namespace cleanup — remove all mokoplatform/MokoStandards/MokoEnterprise refs
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokocli CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
Platform: mokocli CI / Gate 1: Code Quality (pull_request) Failing after 53s
390 files: templates, workflows, MCP servers, CLI tools, lib, deploy,
validate, wrappers, configs, docs. Pure find-and-replace.
2026-06-21 01:18:13 -05:00
gitea-actions[bot] 2904f1ce0e chore(version): pre-release bump to 09.37.04-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 9s
2026-06-21 06:14:47 +00:00
gitea-actions[bot] e17d61e366 chore(version): auto-bump patch 09.37.03-dev [skip ci] 2026-06-21 06:14:35 +00:00
Jonathan Miller 24fbc08bce fix(package): only zip sub-extensions listed in package manifest (#300)
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Universal: Sync Feature Branch Versions / Sync feature branches with dev (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 46s
release_package.php was globbing ALL directories under packages/,
including ones removed from the manifest (backup, license, dbip).
Now parses pkg_*.xml to build allowlist and skips unlisted dirs.
2026-06-21 01:14:20 -05:00
gitea-actions[bot] 2ae287a25b chore(version): pre-release bump to 09.37.02-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 8s
2026-06-21 06:10:16 +00:00
gitea-actions[bot] 66d4c0651d chore(version): auto-bump patch 09.37.01-dev [skip ci] 2026-06-21 06:10:06 +00:00
jmiller 32c500109f fix: remove version field from composer.json for Packagist compatibility
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Universal: Sync Feature Branch Versions / Sync feature branches with dev (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 42s
2026-06-21 05:59:37 +00:00
jmiller 11371cf2d0 feat: add Composer publish workflow for Gitea registry + Packagist
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 46s
2026-06-21 05:45:12 +00:00
jmiller 5948d599f2 chore: rename package to mokoconsulting/mokocli, update description
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 49s
2026-06-21 05:44:48 +00:00
gitea-actions[bot] 7275c8c646 chore: promote changelog [Unreleased] → [09.37.00] 2026-06-21 05:28:23 +00:00
gitea-actions[bot] cb7340ce21 chore(release): build 09.37.00 [skip ci]
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m2s
2026-06-21 05:28:19 +00:00
jmiller a67bf83467 Merge pull request 'feat: interactive repo configuration wizard (#145)' (#294) from feature/145-repo-wizard into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 50s
2026-06-21 05:25:42 +00:00
gitea-actions[bot] 632d8486b8 chore(version): auto-bump patch 09.36.01-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 22s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
2026-06-21 05:25:14 +00:00
Jonathan Miller 558cd6043d fix: address review findings in repo_wizard.php
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m2s
- Fix #1: replace nonexistent menu() with choose() using select()
- Fix #2: constructor — pass name only, not description as version
- Fix #3: respect --non-interactive flag (skip prompts, use defaults)
- Fix #4: use json_encode for composer/package.json (prevent injection)
- Fix #5: remove pointless count() wrapper
- Fix #6: validate --path exists or can be created before proceeding
- Fix TOML description escaping
2026-06-21 00:24:50 -05:00
Jonathan Miller 1cf076f088 feat: interactive repo configuration wizard (#145)
Add `repo:wizard` command — walks through platform selection, generates
config files (phpcs, phpstan, eslint, tsconfig, composer/package.json,
.editorconfig, README, CHANGELOG, .gitignore, workflows), and optionally
creates the repo on Gitea via API.

Supports --dry-run, --non-interactive, and --create-remote flags.
2026-06-21 00:24:49 -05:00
jmiller 00f0e44c78 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 04:46:56 +00:00
gitea-actions[bot] c976f400f4 chore: promote changelog [Unreleased] → [09.36.00] 2026-06-21 04:46:41 +00:00
gitea-actions[bot] ebf37423f2 chore(release): build 09.36.00 [skip ci]
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 42s
2026-06-21 04:46:38 +00:00
jmiller e4d836067f Merge pull request 'feat: deploy:verify — deploy with auto health check and rollback (#147)' (#293) from feature/147-deploy-verify into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 49s
2026-06-21 04:46:21 +00:00
gitea-actions[bot] 8900b12f81 chore(version): auto-bump patch 09.35.02-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 20s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m28s
2026-06-21 04:42:00 +00:00
Jonathan Miller 4fc3d0a4a9 fix: address review findings in deploy-and-verify.php
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 56s
- Fix #1: replace rm -rf with cross-platform PHP removeDirectory()
- Fix #2: sanitize URL in audit log (log hostname only)
- Fix #3: remove unused buildHealthArgs() and $healthArgs
- Fix #4: add random suffix to snapshot dir name for uniqueness
- Fix #5: fix constructor to match CliFramework pattern (no args)
- Fix #6: trigger rollback on deploy failure (partial deploy risk)
2026-06-20 23:41:09 -05:00
gitea-actions[bot] 19aa0111f0 chore(version): auto-bump patch 09.35.01-dev [skip ci] 2026-06-21 04:31:30 +00:00
Jonathan Miller 46e33a9383 feat: deploy:verify — deploy with auto health check and rollback (#147)
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Orchestrates backup → deploy → health-check → rollback-if-failed:
- Pre-deploy snapshot via backup-before-deploy.php
- Deploy via deploy-sftp.php subprocess
- Inline health check with configurable retries and delay
- Auto-rollback via rollback-joomla.php if health check fails
- Post-rollback verification
- Full audit trail via AuditLogger
2026-06-20 23:31:08 -05:00
jmiller 2f43bac247 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 04:10:46 +00:00
gitea-actions[bot] 817e9caee8 chore: promote changelog [Unreleased] → [09.35.00] 2026-06-21 03:22:59 +00:00
gitea-actions[bot] 6216803590 chore(release): build 09.35.00 [skip ci]
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 40s
2026-06-21 03:22:57 +00:00
jmiller caad8ee7d0 Merge pull request 'feat: smart error recovery suggestions in validators (#146)' (#292) from feature/146-recovery-suggestions into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 40s
2026-06-21 03:22:42 +00:00
gitea-actions[bot] 558c0a0edf chore(version): auto-bump patch 09.34.01-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 16s
2026-06-21 03:20:33 +00:00
Jonathan Miller cb1053274e feat: smart error recovery suggestions in validators (#146)
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 48s
- Add RecoverySuggestion class with methods for common fix patterns
  (missing files, directories, XML elements, version mismatches, commands)
- Add suggest() method to CliFramework (yellow lightbulb-prefixed output)
- Integrate into check_structure.php (missing file/dir suggestions)
- Integrate into check_version_consistency.php (version mismatch fixes)
2026-06-20 22:20:16 -05:00
jmiller 743da9c4c2 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 02:50:31 +00:00
jmiller 4b6fcb5fa4 Merge pull request 'fix(version-bump): prevent dev from falling behind stable (#289)' (#291) from feature/289-cherry-pick-version-bump into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m2s
2026-06-21 02:49:47 +00:00
Jonathan Miller e7b2c1fba2 fix(version-bump): prevent dev from falling behind stable (#289)
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m6s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 2m2s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 28s
Cherry-pick from dev (d5fa609). version_bump.php now checks:
1. --min-version argument from workflow
2. Auto-detect from git — scans origin/main and origin/rc for highest
   released version and uses it as the bump base

Closes #289
2026-06-20 21:49:17 -05:00
gitea-actions[bot] 2a45dd873b chore: promote changelog [Unreleased] → [09.34.00] 2026-06-21 02:47:06 +00:00
gitea-actions[bot] e0f1ec1372 chore(release): build 09.34.00 [skip ci]
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m6s
2026-06-21 02:47:03 +00:00
jmiller f325de91d4 Merge pull request 'feat: cross-repo dependency update automation (#149)' (#290) from feature/149-dependency-update-automation into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m3s
2026-06-21 02:46:47 +00:00
gitea-actions[bot] 109493ab4a chore(version): auto-bump patch 09.33.01-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 19s
2026-06-21 02:42:15 +00:00
Jonathan Miller 0d3b14d55c feat: cross-repo dependency update automation (#149)
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Add `deps:update` command that scans org repos for outdated Composer/npm
dependencies, creates PRs with changelogs, and optionally auto-merges
safe patch updates.

- Composer: runs `composer outdated --format=json`, updates targeted packages
- npm: runs `npm outdated --json`, updates targeted packages
- Skips repos with existing deps PRs (no duplicates)
- Checkpoint-based resumability with --resume
- --patch-only for safe updates, --auto-merge for patch PRs
- Supports --repos and --exclude filters
2026-06-20 21:41:43 -05:00
jmiller 35075aa743 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 02:23:11 +00:00
gitea-actions[bot] 7be017ae30 chore: promote changelog [Unreleased] → [09.33.00] 2026-06-21 02:22:56 +00:00
gitea-actions[bot] e8a3414ff4 chore(release): build 09.33.00 [skip ci]
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m25s
2026-06-21 02:22:53 +00:00
jmiller e8697c2d0e Merge pull request 'feat: deploy-sftp.php --env demo/live + multi-instance (#184)' (#287) from feature/184-deploy-env-demo-live into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m23s
2026-06-21 02:22:39 +00:00
gitea-actions[bot] 7d369628f0 chore(version): auto-bump patch 09.32.01-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 16s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m57s
2026-06-21 02:21:55 +00:00
Jonathan Miller e834b8a3ea feat: deploy-sftp.php supports --env demo and --env live with multi-instance (#184)
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
- Add demo and live to ENV_CONFIG_MAP
- Add multi-target deploy via LIVE_TARGETS env var (JSON array of targets)
- Add sftp-config.demo.json.example and sftp-config.live.json.example templates
- Failed targets logged but don't block remaining deploys
2026-06-20 21:21:42 -05:00
gitea-actions[bot] 1655e2a0ae chore: promote changelog [Unreleased] → [09.32.00] 2026-06-21 02:04:14 +00:00
gitea-actions[bot] 4aef631244 chore(release): build 09.32.00 [skip ci]
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m3s
2026-06-21 02:04:11 +00:00
jmiller 6b2cf099f7 Merge pull request 'feat: SourceResolver queries Gitea Manifest API for entry_point (#249)' (#286) from feature/249-source-resolver-api into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m1s
2026-06-21 02:04:01 +00:00
gitea-actions[bot] fefa44965f chore(version): auto-bump patch 09.31.01-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 14s
2026-06-21 02:02:20 +00:00
Jonathan Miller 2b7e38b711 feat: SourceResolver queries Gitea Manifest API for entry_point (#249)
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
SourceResolver::resolve() now checks the Gitea Manifest API first when
GA_TOKEN/GITEA_TOKEN and GITHUB_REPOSITORY are available (CI environments).
Falls back to filesystem detection (source/, src/, htdocs/) when offline.

- API results cached per org/repo for process lifetime
- 5s timeout to avoid blocking local dev
- resolveFromApi() also available as standalone method
- org/repo derived from GITHUB_REPOSITORY env or git remote URL
2026-06-20 21:02:04 -05:00
jmiller 3ed575906f chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 01:40:36 +00:00
gitea-actions[bot] c775cb9447 chore: promote changelog [Unreleased] → [09.31.00] 2026-06-21 01:40:16 +00:00
gitea-actions[bot] 89542f6018 chore(release): build 09.31.00 [skip ci]
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m25s
2026-06-21 01:40:13 +00:00
jmiller bd97b6f79c Merge pull request 'feat: sync feature branch versions with dev after bumps (#250)' (#285) from feature/250-sync-feature-branch-versions into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m28s
2026-06-21 01:39:59 +00:00
Jonathan Miller c461cc5520 feat: sync feature branch versions with dev after bumps (#250)
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 18s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m20s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 2m10s
Add workflow that merges dev into all open feature/fix/patch/hotfix/bugfix/chore
branches when dev gets a version bump commit. Branches with merge conflicts are
skipped with a warning. Can also be triggered manually via workflow_dispatch.
2026-06-20 20:39:38 -05:00
jmiller ae4191bad0 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-21 01:27:28 +00:00
jmiller 73f3d0e734 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-21 01:27:27 +00:00
jmiller 0e7ca2f19a chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-21 01:27:26 +00:00
jmiller 685089f60c chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 01:27:24 +00:00
jmiller 4d3aef3cc5 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 01:27:23 +00:00
jmiller d6d05a1075 chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-21 01:27:22 +00:00
jmiller 1c8bf4e76e chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-21 01:27:21 +00:00
jmiller 6e99d0fac6 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 01:27:19 +00:00
gitea-actions[bot] 5161072b0e chore: promote changelog [Unreleased] → [09.30.00]
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
2026-06-21 01:26:33 +00:00
gitea-actions[bot] 7a4358454d chore(release): build 09.30.00 [skip ci]
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m29s
2026-06-21 01:26:29 +00:00
jmiller a0cc0953c7 Merge pull request 'feat: security advisory aggregator, manifest API rewrite, namespace rename' (#284) from feature/150-security-advisory-aggregator into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m33s
2026-06-21 01:26:10 +00:00
gitea-actions[bot] 79c853354b chore(version): auto-bump patch 09.29.02-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 21s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m3s
2026-06-21 01:25:35 +00:00
Jonathan Miller 033e948c79 feat: security advisory aggregator, manifest API rewrite, namespace rename (#150, #283)
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 7s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m8s
- Add `security:advisories` command — cross-repo CVE scanner via composer audit
  with checkpoint resumability, severity filtering, and auto-issue creation
- Rewrite `manifest:read` to use Gitea manifest API as primary source with
  auto-detection fallback from source tree (no more manifest.xml dependency)
- Rename MokoStandards namespace → MokoCli across all files
- Rename MokoEnterprise namespace → MokoCli across all files
- Rename MokoStandardsParser class → ManifestParser
- Fix composer.json autoload paths: src/ → source/
2026-06-20 20:24:58 -05:00
jmiller 016fda394b chore: delete manifest.xml (no longer used)
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m10s
2026-06-21 00:19:36 +00:00
jmiller 8b18bd73e5 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-20 23:45:51 +00:00
jmiller ffba748762 chore: sync gitleaks.yml from Template-Generic [skip ci] 2026-06-20 23:45:50 +00:00
jmiller d67ff279a1 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-20 23:45:50 +00:00
jmiller fbbc170333 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-20 20:50:56 +00:00
jmiller b574799ca1 chore: sync security-audit.yml from Template-Generic [skip ci] 2026-06-20 20:50:55 +00:00
jmiller 79866a15e5 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-20 20:50:54 +00:00
jmiller a3c18a0248 chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-20 20:50:53 +00:00
jmiller 57392c254e chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-20 20:50:52 +00:00
jmiller acdc0e903c chore: sync notify.yml from Template-Generic [skip ci] 2026-06-20 20:50:51 +00:00
jmiller 55af5b0feb chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-20 20:50:50 +00:00
jmiller 47ab0de146 chore: sync gitleaks.yml from Template-Generic [skip ci] 2026-06-20 20:50:49 +00:00
jmiller cfdb0094ba chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-20 20:50:48 +00:00
jmiller 5e8092a1c9 chore: sync cleanup.yml from Template-Generic [skip ci] 2026-06-20 20:50:47 +00:00
jmiller 3699edbf62 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-20 20:50:46 +00:00
jmiller c5860d4074 chore: sync branch-cleanup.yml from Template-Generic [skip ci] 2026-06-20 20:50:45 +00:00
jmiller 5d8fa2b1d3 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-20 20:50:44 +00:00
jmiller 9f3dd6f790 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-20 20:50:43 +00:00
jmiller 4ccc850edf Merge pull request 'fix: add missing src/functions.php referenced by composer autoload' (#282) from fix/missing-functions-php into main
Generic: Project CI / Tests (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Lint & Validate (push) Failing after 38s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m19s
2026-06-20 19:03:55 +00:00
Moko Consulting 957460db03 fix: add missing src/functions.php referenced by composer autoload
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 11s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Failing after 23s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m13s
2026-06-20 18:48:40 +00:00
jmiller 8b014ad0a9 feat: implement release zip cascade to lower stability channels (#280)
Generic: Project CI / Tests (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Failing after 57s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m16s
2026-06-20 17:40:03 +00:00
jmiller f942615a12 fix: add branch+PR fallback for protected repos, rename moko-platform to mokocli
Generic: Project CI / Tests (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Failing after 39s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 44s
2026-06-20 17:30:34 +00:00
jmiller 116d94dd8c fix: add branch+PR fallback for protected repos, rename moko-platform to mokocli
Generic: Project CI / Tests (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Failing after 44s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 47s
2026-06-20 17:30:33 +00:00
jmiller a918cb38b2 Merge pull request 'fix: rename package_type to extension_type, remove display_name (#259)' (#281) from fix/259-cherry-pick into main
Generic: Project CI / Tests (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Failing after 32s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 46s
2026-06-20 17:15:50 +00:00
Jonathan Miller dc7f6c9eeb fix: improve error handling, add extension_type fallback (#259)
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 14s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 10s
Generic: Project CI / Lint & Validate (pull_request) Failing after 44s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m17s
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
- Surface HTTP errors instead of suppressing with @file_get_contents
- Add specific messages for 401/403/404 and missing token
- Fall back to package_type if extension_type not in API response
- Log warnings for malformed XML candidates
- Fix platform_detect.php endpoint from /manifest to /metadata
2026-06-20 12:14:08 -05:00
Jonathan Miller 0b1f39a75c fix: rename package_type to extension_type, remove display_name validation (#259)
- API endpoint updated from /manifest to /metadata
- Removed dead .mokogitea/manifest.xml local file fallback
- display_name is now server-computed, no longer validated
- package_type renamed to extension_type throughout
2026-06-20 12:14:07 -05:00
jmiller db21aca7d0 Merge pull request 'fix: rename Template-Client-Waas submodule to Template-Client' (#275) from fix/rename-client-waas-submodule into main
Generic: Project CI / Tests (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Failing after 49s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 58s
2026-06-20 16:00:42 +00:00
Jonathan Miller 463ae43e64 fix: rename Template-Client-Waas submodule to Template-Client
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Failing after 44s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m41s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 7s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
2026-06-20 11:00:15 -05:00
jmiller 93c524b655 Merge pull request 'refactor: rename src/ to source/' (#274) from fix/rename-src-to-source-v2 into main
Generic: Project CI / Tests (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Failing after 30s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m1s
2026-06-20 16:00:11 +00:00
jmiller 2e0f3554db Merge pull request 'refactor: replace template dirs with git submodules' (#273) from fix/templates-to-submodules into main
Generic: Project CI / Tests (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Failing after 1m1s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m41s
2026-06-20 15:58:25 +00:00
Jonathan Miller 5521a7f81e refactor: replace template dirs with git submodules
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Project CI / Lint & Validate (pull_request) Failing after 24s
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m46s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Remove embedded template copies from templates/repos/ and replace
with submodules pointing to the canonical template repos:

- Template-Client-Waas -> Template-Client
- Template-Generic -> Template-Generic
- Template-Joomla -> Template-Joomla
- Template-MCP -> Template-MCP

Dolibarr template removed (no longer needed).
Submodule paths match repo names for consistency.

Use `git submodule update --init` to populate after clone.
2026-06-20 10:56:40 -05:00
Jonathan Miller 3331442381 refactor: rename src/ to source/ for consistency
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Project CI / Lint & Validate (pull_request) Failing after 31s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 7s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
2026-06-20 10:56:35 -05:00
Jonathan Miller 62b19d516f refactor: replace template dirs with git submodules
Remove embedded template copies from templates/repos/ and replace
with submodules pointing to the canonical template repos:

- client-waas -> Template-Client
- generic -> Template-Generic
- joomla -> Template-Joomla
- mcp -> Template-MCP

Dolibarr template removed (no longer needed).

Templates are now maintained in their own repos. Use
`git submodule update --init` to populate after clone.
2026-06-20 10:52:08 -05:00
jmiller 8d2ac56f0c Merge pull request 'chore: rename GA_TOKEN/GH_TOKEN secret references' (#272) from fix/rename-secret-refs into main
Generic: Project CI / Tests (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Project CI / Lint & Validate (push) Failing after 31s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m17s
2026-06-20 15:30:20 +00:00
Jonathan Miller 12a16b178a chore: rename secret references for readability
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 10s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Generic: Project CI / Lint & Validate (pull_request) Failing after 27s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 12s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m22s
GA_TOKEN -> MOKOGITEA_TOKEN
GH_TOKEN -> GH_PAT

Applied across all workflows, templates, and embedded MCP servers.
Secrets need to be updated in repo settings to match.
2026-06-20 10:29:48 -05:00
jmiller f8e21dce5a fix(ci): detect rebuild by branch name not version suffix [skip ci] 2026-06-19 01:59:20 +00:00
jmiller 1e4d3d5b3f ci: patch bump on same-branch rebuilds, minor only on elevation [skip ci] 2026-06-19 01:06:59 +00:00
jmiller 78fa142342 Merge pull request 'feat: joomla_metadata_validate CLI command (#257)' (#258) from feature/257-joomla-metadata-validate into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Project CI / Lint & Validate (push) Failing after 18s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 50s
Generic: Project CI / Tests (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (push) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokoplatform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-18 20:55:12 +00:00
gitea-actions[bot] 7ac196e4a8 chore(version): auto-bump patch 09.29.01-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
2026-06-18 20:53:54 +00:00
Jonathan Miller 2a3e733d8c feat: add joomla_metadata_validate CLI command (#257)
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Validates MokoGitea repo metadata against the actual Joomla extension
manifest XML to catch update delivery mismatches before production.

Checks:
- package_type matches <extension type>
- Element name derived correctly (prefix + lowercase + clean)
- Display name matches <name> tag
- Version consistency (ignoring -dev/-rc suffixes)
- PHP minimum matches composer.json
- Description match (informational)

Supports:
- Local mode: reads .mokogitea/manifest.xml + Joomla XML from disk
- API mode: fetches metadata via Gitea API (--token)
- CI mode: --ci flag exits 1 on errors
- JSON output: --json for workflow integration

Handles all Joomla types: package, component, module, plugin,
template, library, file. Replicates Joomla's InputFilter::clean('cmd')
for element name derivation.

Refs mokoplatform #257
2026-06-18 15:53:43 -05:00
jmiller 3abd08da44 ci: add changelog extraction to promote-rc job [skip ci] 2026-06-18 16:41:04 +00:00
Jonathan Miller 7269800891 fix: restore full workflow_sync.php with PLATFORM_EXCLUDES
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Project CI / Lint & Validate (push) Failing after 17s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 52s
Generic: Project CI / Tests (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (push) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokoplatform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Previous API pushes truncated the file content. This commit restores
the complete file with all changes: PLATFORM_EXCLUDES constant,
Phase 1 and Phase 2 exclusion checks, version bump to 09.30.00.
2026-06-18 11:34:59 -05:00
jmiller b038a49279 fix: push full workflow_sync.php with PLATFORM_EXCLUDES (replace truncated content)
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Lint & Validate (push) Failing after 25s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 38s
Generic: Project CI / Tests (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (push) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokoplatform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-18 16:19:28 +00:00
jmiller 9b05cd5fc5 fix: push actual workflow_sync.php with PLATFORM_EXCLUDES (previous commit had placeholder content)
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Failing after 19s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 54s
Generic: Project CI / Tests (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (push) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokoplatform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-18 16:15:29 +00:00
jmiller 2846c361e0 feat: add PLATFORM_EXCLUDES to workflow_sync - skip deploy-manual.yml for Joomla
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Project CI / Lint & Validate (push) Failing after 22s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m4s
Generic: Project CI / Tests (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (push) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokoplatform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-18 16:13:40 +00:00
jmiller 7ccf2585dd ci: disable auto-bump on push to dev [skip ci] 2026-06-16 19:40:38 +00:00
jmiller 5c31771037 feat(cli): add semver fallback for package.json version bump
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Lint & Validate (push) Failing after 29s
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 1m27s
Generic: Project CI / Tests (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (push) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokoplatform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
When package.json uses standard x.y.z semver instead of
MokoPlatform XX.YY.ZZ format, auto-bump using the same
bump type (major/minor/patch). Respects --minor and --major flags.
2026-06-11 07:29:20 +00:00
gitea-actions[bot] 8de72a386d chore(release): build 09.29.00 [skip ci] 2026-06-09 19:40:34 +00:00
jmiller 05aafbac61 Merge pull request 'feat: detect display_name, target_version, php_minimum in manifest_detect' (#254) from feat/manifest-detect-update-fields into main 2026-06-09 19:40:08 +00:00
Jonathan Miller 3c57e87066 feat: detect display_name, target_version, php_minimum in manifest_detect
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Universal: PR Check / Branch Policy (pull_request) Failing after 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 4s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 12s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 21s
Generic: Project CI / Lint & Validate (pull_request) Failing after 34s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 33s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m37s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokoplatform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
These fields are now required by MokoGitea's update feed generator
which reads them from the manifest API (#592).

- display_name: from XML <name> with type prefix detection
- target_version: from <targetplatform version=""> or default (5|6)
- php_minimum: from <php_minimum> tag
2026-06-09 14:39:54 -05:00
gitea-actions[bot] e37443774b chore(release): build 09.28.00 [skip ci] 2026-06-07 22:30:41 +00:00
jmiller e6b9a3b4f6 Merge pull request 'chore: replace MokoJoom references with MokoSuite' (#253) from feat/manifest-integrity into main
Generic: Project CI / Tests (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (push) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokoplatform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Project CI / Lint & Validate (push) Has been cancelled
Platform: mokoplatform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 22:30:31 +00:00
Jonathan Miller d0200c28f0 chore: replace MokoJoom references with MokoSuite in templates
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokoplatform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
RC Revert / Rename rc/ back to dev/ (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Has been cancelled
Generic: Project CI / Lint & Validate (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
- Makefile: use $(EXTENSION_NAME) variable instead of hardcoded
  mokojoomgallery, update example name to mokosuite
- composer.json: update template package name and description
- mokobackup: rename MokoJoomBackup to MokoSuite Backup in docs/code
2026-06-07 17:26:58 -05:00
gitea-actions[bot] 1f946c0b75 chore(release): build 09.27.00 [skip ci] 2026-06-07 21:30:44 +00:00
jmiller d4e2d36301 Merge pull request 'feat: manifest_integrity.php — org-wide manifest validation' (#252) from feat/manifest-integrity into main
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (push) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokoplatform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: mokoplatform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 21:30:32 +00:00
Jonathan Miller a2c1a61759 feat: add manifest_integrity.php for org-wide manifest validation
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokoplatform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
RC Revert / Rename rc/ back to dev/ (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Has been cancelled
Generic: Project CI / Lint & Validate (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
Cross-checks manifest API fields against repo contents.
Three modes: local (--path), single remote (--repo), bulk org.
Validates required fields, platform-specific rules, and version format.
Supports --fix to auto-push corrections, --json for CI consumption.
2026-06-07 16:25:49 -05:00
gitea-actions[bot] 0d12bca60e chore(release): build 09.26.00 [skip ci] 2026-06-07 20:38:11 +00:00
jmiller 7cabc6e5ef Merge pull request 'feat: manifest_detect.php + dev sync + workflow updates' (#251) from feat/manifest-detect into main
Generic: Project CI / Tests (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (push) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokoplatform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Project CI / Lint & Validate (push) Has been cancelled
Platform: mokoplatform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 20:37:54 +00:00
Jonathan Miller b73c1eba25 feat: add manifest_detect.php CLI tool for auto-detecting manifest fields
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokoplatform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
RC Revert / Rename rc/ back to dev/ (pull_request) Has been cancelled
Universal: Security Audit / Dependency Audit (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
Generic: Project CI / Lint & Validate (pull_request) Has been cancelled
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Scans source files to detect platform, name, version, element_name,
package_type, language, entry_point, description, and license_spdx.
Supports Joomla, Dolibarr, Go, MCP/Node, and generic platforms.

Includes --diff and --update modes for comparing against and pushing
to the Gitea manifest API. Warns on missing core fields.

Also removes deprecated mcp/servers/mokowaas_api (consolidated to
separate repo) and syncs dev branch changes.
2026-06-07 15:37:24 -05:00
jmiller e5ab1a4cd5 chore: sync security-audit.yml from Template-Generic [skip ci] 2026-06-07 17:55:25 +00:00
jmiller f81873cf37 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-07 17:55:24 +00:00
jmiller d9e15b4f4a chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-07 17:55:23 +00:00
jmiller 31b4bbca2a chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-07 17:55:23 +00:00
jmiller a7f07b77a2 chore: sync notify.yml from Template-Generic [skip ci] 2026-06-07 17:55:22 +00:00
jmiller 3e84bc9a18 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-07 17:55:21 +00:00
jmiller 7e8c96b629 chore: sync gitleaks.yml from Template-Generic [skip ci] 2026-06-07 17:55:21 +00:00
jmiller 2bf88d1d17 chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-07 17:55:20 +00:00
jmiller 2160cd1ce7 chore: sync cleanup.yml from Template-Generic [skip ci] 2026-06-07 17:55:19 +00:00
jmiller 6b61d9a211 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-07 17:55:19 +00:00
jmiller 1fbffbcb57 chore: sync branch-cleanup.yml from Template-Generic [skip ci] 2026-06-07 17:55:18 +00:00
jmiller f910a8ad12 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-07 17:55:17 +00:00
jmiller 5439df3876 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-07 17:55:17 +00:00
Jonathan Miller ab0c209897 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 12:44:31 -05:00
jmiller e4d9bce5d0 docs: update changelog with workflow_sync, platform_detect, and version_prefix
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-07 17:35:58 +00:00
gitea-actions[bot] e933e7b651 chore(version): auto-bump patch 09.25.02-dev [skip ci] 2026-06-07 17:35:06 +00:00
jmiller 157e87279e feat: add version_prefix support to version_bump — prefix-aware find/replace in source files
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 17:35:01 +00:00
gitea-actions[bot] 7850721f86 chore(version): auto-bump patch 09.25.01-dev [skip ci] 2026-06-07 17:34:03 +00:00
jmiller 8949f69699 feat: add version_prefix support — prefix-aware version read and bump
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 17:33:55 +00:00
jmiller af2313d936 feat: add platform_detect.php — auto-detect repo platform and update manifest
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 17:30:52 +00:00
jmiller 2e5446ff5e feat: add workflow_sync.php — cascading sync based on manifest.platform
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 17:27:49 +00:00
jmiller 17a50851fb fix: gitignore site/ should be /site/ to avoid matching tmpl/site/
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 03:18:38 +00:00
jmiller ef3614d249 fix: gitignore site/ should be /site/ to avoid matching tmpl/site/
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 03:17:58 +00:00
jmiller ab05bb7008 chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-06-06 19:48:11 +00:00
Jonathan Miller 3d5d581883 Merge branch 'dev'
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-06 13:00:10 -05:00
Jonathan Miller 6bd26698c4 fix: check for manifest_element.php in pre-installed tools validation
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Runner image has stale /opt/moko-platform missing manifest_element.php.
Adding it to the existence check forces a fresh clone until the image
is rebuilt.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 12:53:27 -05:00
Jonathan Miller 19b504526b fix: remove double quotes from shell commands in workflow YAML
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
act_runner passes run: | blocks through a shell that treats double
quotes as literal characters in some contexts. Removed all double
quotes from echo, test, and git clone commands. Git clone URL is
now built in a variable to avoid quoting issues with the token.

Fixes pre-release and auto-release workflows failing with:
  fatal: protocol '"https' is not supported

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 12:44:44 -05:00
Jonathan Miller e7bdf7cbc7 fix: load Composer autoloader in CliFramework constructor (#248)
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
CLI tools failed with "Class MokoEnterprise\SourceResolver not found"
because the Composer autoloader was never loaded. The require_once
for CliFramework.php loaded the framework but not the PSR-4 autoloader
that maps MokoEnterprise\ to lib/Enterprise/.

Adding require_once for vendor/autoload.php in the constructor ensures
all Enterprise classes (SourceResolver, etc.) are available to every
CLI tool that extends CliFramework.

Closes #248

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 12:34:33 -05:00
Jonathan Miller ff5794d0cc fix: remove dead definitionParser reference in RepositorySynchronizer
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
The definitionParser property was never initialized or implemented.
synchronizeRepository() crashed with "Call to a member function
parseForPlatform() on null". Replaced with direct use of
getSharedWorkflows() which provides all files to sync.
2026-06-06 12:09:14 -05:00
Jonathan Miller bd5f676e0a Merge remote-tracking branch 'origin/dev'
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
# Conflicts:
#	cli/manifest_licensing.php
2026-06-06 11:49:30 -05:00
Jonathan Miller bfba45e8b5 chore: remove deprecated updates.xml build/sync from workflows
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
MokoGitea generates update feeds dynamically from releases.
Static updates.xml is no longer needed.
2026-06-06 11:24:03 -05:00
Jonathan Miller 78ea05233b fix: update workflow path comments and token references
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
- Replace .gitea/ with .mokogitea/ in PATH comments
- Standardize token names to MOKOGITEA_TOKEN
- Remove github.token fallback patterns
2026-06-06 11:11:10 -05:00
Jonathan Miller ae0d54310d fix: replace smart quotes with ASCII in pre-release.yml (#245)
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Unicode smart quotes (U+201C/U+201D) in the Setup moko-platform tools
step caused `fatal: protocol '"https' is not supported` during git clone.

Closes #245

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:42 -05:00
Jonathan Miller 9df59836bf chore: update CLAUDE.md template with first-run setup and focused format
Replace verbose boilerplate with platform-specific scaffold including
first-run setup checklist and placeholder tokens for new repos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:42 -05:00
Jonathan Miller 6e40707223 chore: move CLAUDE.md to .mokogitea/ directory
Relocate CLAUDE.md from repo root to .mokogitea/ per project convention.
Content updated with focused, repo-specific architecture and rules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:41 -05:00
Jonathan Miller ca55e5d2d2 feat(core): add SourceResolver for backwards-compatible src/ → source/ migration
Introduces SourceResolver utility class with source/ → src/ → htdocs/
fallback chain, replacing hardcoded src/ references across 28 files.
This enables renaming root-level src/ to source/ in all repos while
maintaining backwards compatibility during the transition.

Phase 1: New lib/Enterprise/SourceResolver.php with resolve(),
resolveAbsolute(), globSource(), findUnderSource(), warnIfLegacy()
Phase 2: Updated 19 CLI/deploy tools to use SourceResolver
Phase 3: Updated 7 validator/lib files (McpServerPlugin,
PackageBuilder, RepositorySynchronizer, auto_detect_platform,
check_dolibarr_module, check_client_theme, check_structure)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:41 -05:00
jmiller 9526d006c4 feat(ci): add manifest_licensing step to pre-release workflow
Ensures updateservers, dlid, and blockChildUninstall tags are
present in Joomla extension manifests when licensing is enabled.

Authored-by: Moko Consulting
2026-06-06 10:23:41 -05:00
Jonathan Miller c90a5671bd feat(cli): add manifest_licensing.php for update server and dlid management
New CLI tool that reads <licensing> from manifest.xml and ensures
Joomla extension manifests have correct updateservers, dlid, and
blockChildUninstall tags. Supports dry-run and --fix modes.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 10:23:40 -05:00
gitea-actions[bot] 048a7d71d1 chore(release): build 09.25.00 [skip ci] 2026-06-06 10:23:40 -05:00
jmiller 847263dd86 feat(ci): add manifest_licensing step to pre-release workflow
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Ensures updateservers, dlid, and blockChildUninstall tags are
present in Joomla extension manifests when licensing is enabled.

Authored-by: Moko Consulting
2026-06-06 12:27:19 +00:00
Jonathan Miller 6e540f64c4 feat(cli): add manifest_licensing.php for update server and dlid management
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
New CLI tool that reads <licensing> from manifest.xml and ensures
Joomla extension manifests have correct updateservers, dlid, and
blockChildUninstall tags. Supports dry-run and --fix modes.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:19:19 -05:00
jmiller c57b5724ac chore: remove update-server docs [skip ci] 2026-06-05 00:55:13 +00:00
jmiller 78affd37ff chore: remove update-server docs [skip ci] 2026-06-05 00:55:12 +00:00
jmiller b3062c6559 chore: remove update-server docs [skip ci] 2026-06-05 00:55:11 +00:00
jmiller cf02738930 chore: remove update-server docs [skip ci] 2026-06-05 00:55:10 +00:00
jmiller 455d4c8a19 chore: remove update-server docs [skip ci] 2026-06-05 00:55:09 +00:00
jmiller 8286d493b9 chore: remove update-server docs [skip ci] 2026-06-05 00:55:08 +00:00
jmiller b740152d67 Merge pull request 'ci: pre-installed /opt/moko-platform on runner' (#244) from dev into main
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-04 23:43:57 +00:00
Jonathan Miller 9dab9f1ef6 ci: use pre-installed /opt/moko-platform on runner, fallback to clone
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
All workflows check for /opt/moko-platform first (updated by cron
every 6h). Falls back to fresh clone if not available.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 18:43:03 -05:00
Jonathan Miller c61d32709c chore: remove deprecated update-server.yml workflow [skip ci]
Authored-by: Moko Consulting
2026-06-04 18:42:33 -05:00
gitea-actions[bot] a59091e348 chore(release): build 09.25.00 [skip ci] 2026-06-04 23:03:39 +00:00
jmiller ee3d58c20f Merge pull request 'ci: remove updates.xml workflow steps, use CLI version_bump for pre-release' (#243) from dev into main
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-04 23:03:26 +00:00
Jonathan Miller 2b137f9041 Merge remote-tracking branch 'origin/main' into dev
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
# Conflicts:
#	.mokogitea/workflows/pre-release.yml
#	CHANGELOG.md
2026-06-04 18:02:23 -05:00
Jonathan Miller f3f356ae54 ci: remove updates.xml steps from pre-release, use CLI version_bump
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- Remove updates_xml_build.php, updates.xml commit/push, branch sync steps
- MokoGitea license server generates updates.xml dynamically
- Pre-release now uses version_bump.php (patch for dev, --minor for RC)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:58:43 -05:00
Jonathan Miller 85d863be08 fix: pre-release bumps version via CLI (patch for dev, minor for RC)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Each dev/alpha/beta pre-release now bumps patch so Joomla sees a
higher version and offers the update. RC bumps minor to consolidate
dev patches into a clean release version.

Uses version_bump.php from CLI — no inline shell math in workflows.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 16:51:44 -05:00
Jonathan Miller a83eda5798 chore: remove build/ directory from tracking
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Build artifacts are created by CI, not tracked in source.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 13:25:50 -05:00
gitea-actions[bot] 54a27c0a8f chore(release): build 09.24.00 [skip ci] 2026-06-04 16:29:58 +00:00
jmiller 5754fae5a8 Merge pull request 'feat: --skip-update-stream flag + CI workflow updates' (#227) from dev into main
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
feat: --skip-update-stream flag + CI workflow updates
2026-06-04 16:29:47 +00:00
gitea-actions[bot] ab3c0a3a8d fix: ensure all pre-releases marked prerelease=true [skip ci] 2026-06-04 16:10:52 +00:00
jmiller eb3689cff6 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:58:05 +00:00
jmiller 631b44e1a3 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
2026-06-04 15:56:16 +00:00
jmiller 7338a3da2e chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:40:31 +00:00
jmiller 0a0e1f11e0 docs: add Dolibarr Update Server standards [skip ci] 2026-06-04 15:39:23 +00:00
jmiller c3a3ab3f62 docs: add Joomla Update Server standards [skip ci] 2026-06-04 15:38:29 +00:00
jmiller 79631d77bb chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:38:25 +00:00
jmiller 556ac85a63 chore: remove static updates.xml.template - now served dynamically [skip ci] 2026-06-04 15:36:21 +00:00
jmiller c1a145480c chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:31:53 +00:00
jmiller 4d06e3828e chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:28:53 +00:00
jmiller ab7b6cfba1 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:18:43 +00:00
jmiller e135a0ff8b chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:13:23 +00:00
gitea-actions[bot] 2d6155d655 chore: sync pre-release workflow — auto RC on PR to main [skip ci] 2026-06-04 14:45:48 +00:00
jmiller 65215cdc4c chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-04 14:23:09 +00:00
jmiller 86db53d2ac chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-04 14:20:21 +00:00
Jonathan Miller 8a4e1ab60f feat(cli): add --skip-update-stream flag to release_publish
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Allows release workflows to skip updates.xml generation and sync.
This decouples release creation from update stream management,
enabling updates.xml to be managed externally (e.g. Gitea Pages).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 09:16:42 -05:00
jmiller 8c87cf1e74 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-04 13:46:56 +00:00
jmiller 505013c6f1 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 09:36:54 +00:00
jmiller 2f6845c5c0 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 03:10:37 +00:00
jmiller 45233fb9d2 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-02 23:47:13 +00:00
jmiller ecf6615383 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-02 21:51:30 +00:00
Jonathan Miller 59d3524615 feat(ci): auto-file Gitea issues when CI gates fail
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Add ci-issue-reporter.sh shared script that creates or updates issues
via the Gitea API with dedup (searches open issues by ci-auto label).
Integrated into ci-platform, repo-health, and pr-check workflows.

Also fix push_files.php: getFileContent() → getFileContents() with
base64 decode to match the MokoGiteaAdapter interface.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:42:04 -05:00
Jonathan Miller 8058baef95 feat(cli): detect src/ entry-point in Joomla package sub-extensions
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
When building a Joomla package, sub-package directories that contain a
src/ folder with a Joomla manifest XML (e.g. git submodules of full
repos) now zip src/ instead of the repo root. This avoids bundling
README, CI config, and other repo-level files into extension packages.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:10:40 -05:00
Jonathan Miller df2efa4838 Merge fix/cli-banner-stderr: route all decorative CLI output to stderr
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Properly fixes the root cause that b53846f worked around — instead of
adding --quiet flags to individual callers, all CliFramework display
methods now write to stderr via display(), keeping stdout clean for
machine-readable data in all CLI scripts.

Authored-by: Moko Consulting
2026-06-02 10:07:34 -05:00
Jonathan Miller 76bc91a383 fix: route all decorative CLI output to stderr
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
All display methods (banner, progress, section, status, log, table,
summary box, divider, step) now write to stderr via a new display()
helper. This ensures stdout is reserved for machine-readable data,
fixing CI pipelines that capture CLI output with $() or redirect to
$GITHUB_OUTPUT.

Root cause: version_read.php banner was written to stdout, so
  VERSION=$(php version_read.php --path .)
captured the box-drawing characters along with the version string,
corrupting $GITHUB_OUTPUT and breaking downstream release steps.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:01:16 -05:00
Jonathan Miller b53846f6f4 fix: prevent version_read banner from corrupting XML manifests
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
- Add --quiet flag to version_read.php call in version_auto_bump.php
  so the CliFramework banner doesn't pollute stdout
- Parse version output by matching XX.YY.ZZ pattern instead of
  blindly taking the first line
- Add version format validation in version_set_platform.php to reject
  non-XX.YY.ZZ values before writing to XML files

Root cause: exec() captured the decorative banner output from
version_read.php and version_set_platform.php's regex replacement
injected it into <version> tags across all Joomla manifests.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:01:19 -05:00
Jonathan Miller 11eb1e2649 chore(release): bump to 09.23.00 — plugin commands, audit query, version fix
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 14:18:26 -05:00
Jonathan Miller cb2debc437 feat(cli): populate plugin commands and add audit:query tool (#148, #144)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
#148: Override getCommands() in 5 plugins — JoomlaPlugin (5 commands),
DolibarrPlugin (3), NodeJsPlugin (2), PythonPlugin (2), WordPressPlugin
(3). All 15 commands appear in `php bin/moko list` and resolve to
existing validation/build/deploy scripts.

#144: New cli/audit_query.php — search, filter, and export JSONL audit
logs with --service, --user, --event, --level, --since, --until filters.
Supports table, json, jsonl output formats and --stats summary mode.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 14:12:38 -05:00
Jonathan Miller 1ecd9239ed fix(version): preserve suffix on bump, simplify workflow version logic (#191)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
version_bump.php now captures the existing suffix (e.g. -dev) from
manifest.xml and re-applies it after incrementing the base version.
This lets workflows read the version as-is instead of stripping and
re-applying suffixes based on branch names.

Simplified update-server.yml and pre-release.yml by removing the
strip→map→re-apply suffix dance. Removed DISPLAY_VERSION and SUFFIX
output variables — VERSION now carries the full suffixed string.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 14:01:34 -05:00
Jonathan Miller 66e728b078 style: fix PHPCS violations across migrated CLI scripts
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Auto-fixed 5006 tab-indent and line-ending errors via phpcbf, then
manually broke 100 lines exceeding 150-char limit. All 74 files in
cli/, automation/, maintenance/, deploy/ now pass PHPCS PSR-12 clean.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 13:36:05 -05:00
Jonathan Miller ae2860c3b5 chore(release): bump to 09.22.00 — CliFramework migration
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 12:14:34 -05:00
Jonathan Miller 34ab5c43ee docs: update CHANGELOG with CliFramework migration and recent fixes
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 12:07:59 -05:00
Jonathan Miller 154d6911f9 Merge branch 'main' into dev
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
# Conflicts:
#	cli/release_create.php
#	cli/release_publish.php
#	cli/updates_xml_build.php
2026-05-31 11:40:18 -05:00
Jonathan Miller b3d9ee8255 refactor(cli): migrate 64 legacy scripts to CliFramework (#235)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Wrap all CLI tools in cli/, automation/, maintenance/, deploy/, and
release/ in classes extending CliFramework. Replaces manual $argv
parsing with configure()/addArgument(), moves logic into run(): int,
and converts fwrite(STDERR,...) to $this->log(). Two CLIApp subclasses
(generate_dolibarr_version_txt, generate_joomla_update_xml) converted
to extend CliFramework directly.

Every script now gets free --help, --verbose, --quiet, --dry-run,
--json, --no-color, banners, coloured logging, and progress bars.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:39:10 -05:00
Jonathan Miller 4da7ecd38e fix: restore hyphen in version suffixes
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Reverts suffix change — keeps hyphen: 02.30.00-dev, 02.30.00-rc, etc.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:09 -05:00
Jonathan Miller 2e1eb9b8f9 fix: remove hyphen from version suffixes in release names
Version suffixes are now appended without a hyphen:
  02.30.00dev instead of 02.30.00-dev
  02.30.00rc instead of 02.30.00-rc

This matches the standard release name format:
  Package - MokoWaaS (VERSION: 02.30.00dev)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:09 -05:00
Jonathan Miller 59720f1533 fix: release names use standardized format
Changed release name from:
  "Package - MokoWaaS 02.29.04 (pkg_mokowaas-02.29.04)"
To:
  "Package - MokoWaaS (VERSION: 02.29.04)"

Closes #242

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:08 -05:00
Jonathan Miller 930776a6ff fix(cli): auto-detect org/repo in updates_xml_build from manifest and git remote
When --org/--repo are not provided and env vars are unset, the tool now
reads <org> and <name> from .mokogitea/manifest.xml, with a final
fallback to parsing the git remote URL. Fixes broken URLs with empty
org/repo segments in generated updates.xml.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:08 -05:00
jmiller 4453a2e127 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-31 11:15:08 -05:00
Jonathan Miller 0363597c85 fix: remove lesser stream copies, each stream updates independently
Joomla picks the HIGHEST version via version_compare() across ALL
entries. Lesser stream copies (02.17.00-dev) at the same base version
as stable (02.17.00) would never be selected because dev < stable.

Each stream now updates only when its own release happens:
- stable: on merge to main
- dev: on auto-bump after dev recreated from main
- rc: on promote-rc

The dev stream naturally gets 02.17.01-dev (higher than 02.17.00)
because auto-bump runs on the recreated dev branch.

Also sorts updates.xml entries dev→stable (dev first).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:08 -05:00
Jonathan Miller 547fc5ead8 fix: sort updates.xml entries dev first, stable last [skip ci]
Joomla reads entries top-down. Sorting dev→alpha→beta→rc→stable
ensures proper display ordering in the Joomla update manager.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:15:07 -05:00
Jonathan Miller af77e9d361 fix: restore hyphen in version suffixes
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Reverts suffix change — keeps hyphen: 02.30.00-dev, 02.30.00-rc, etc.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:12:42 -05:00
Jonathan Miller 7565ef2171 fix: remove hyphen from version suffixes in release names
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Version suffixes are now appended without a hyphen:
  02.30.00dev instead of 02.30.00-dev
  02.30.00rc instead of 02.30.00-rc

This matches the standard release name format:
  Package - MokoWaaS (VERSION: 02.30.00dev)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:10:08 -05:00
Jonathan Miller f724eaa26e fix: release names use standardized format
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Changed release name from:
  "Package - MokoWaaS 02.29.04 (pkg_mokowaas-02.29.04)"
To:
  "Package - MokoWaaS (VERSION: 02.29.04)"

Closes #242

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 11:05:41 -05:00
Jonathan Miller 7f1307bf05 fix(cli): auto-detect org/repo in updates_xml_build from manifest and git remote
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
When --org/--repo are not provided and env vars are unset, the tool now
reads <org> and <name> from .mokogitea/manifest.xml, with a final
fallback to parsing the git remote URL. Fixes broken URLs with empty
org/repo segments in generated updates.xml.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 08:03:04 -05:00
jmiller d0d778fae8 chore: sync .mokogitea/workflows/cascade-dev.yml from moko-platform [skip ci] 2026-05-31 01:45:19 +00:00
jmiller c4aaf5bd2c chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-31 01:09:57 +00:00
Jonathan Miller d96c5ac420 fix: remove lesser stream copies, each stream updates independently
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Joomla picks the HIGHEST version via version_compare() across ALL
entries. Lesser stream copies (02.17.00-dev) at the same base version
as stable (02.17.00) would never be selected because dev < stable.

Each stream now updates only when its own release happens:
- stable: on merge to main
- dev: on auto-bump after dev recreated from main
- rc: on promote-rc

The dev stream naturally gets 02.17.01-dev (higher than 02.17.00)
because auto-bump runs on the recreated dev branch.

Also sorts updates.xml entries dev→stable (dev first).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 19:53:47 -05:00
Jonathan Miller a8ef5f1090 fix: sort updates.xml entries dev first, stable last [skip ci]
Joomla reads entries top-down. Sorting dev→alpha→beta→rc→stable
ensures proper display ordering in the Joomla update manager.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 19:50:11 -05:00
Jonathan Miller a9147bb7a4 fix: disable cascade-dev [skip ci] 2026-05-30 18:08:53 -05:00
739 changed files with 99321 additions and 19595 deletions
+12
View File
@@ -0,0 +1,12 @@
[submodule "templates/repos/Template-Client"]
path = templates/repos/Template-Client
url = https://git.mokoconsulting.tech/MokoConsulting/Template-Client.git
[submodule "templates/repos/Template-Generic"]
path = templates/repos/Template-Generic
url = https://git.mokoconsulting.tech/MokoConsulting/Template-Generic.git
[submodule "templates/repos/Template-Joomla"]
path = templates/repos/Template-Joomla
url = https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla.git
[submodule "templates/repos/Template-MCP"]
path = templates/repos/Template-MCP
url = https://git.mokoconsulting.tech/MokoConsulting/Template-MCP.git
+76
View File
@@ -0,0 +1,76 @@
# mokocli
Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories.
## Quick Reference
| Field | Value |
|---|---|
| **Language** | PHP 8.1+ |
| **Version** | 09.01.00 |
| **Branch** | develop on `dev`, merge to `main` (protected) |
| **Wiki** | [mokocli Wiki](https://git.mokoconsulting.tech/MokoConsulting/mokocli/wiki) |
## Commands
```bash
composer install # Install PHP dependencies
php bin/moko health --path . # Repo health check
php bin/moko check:syntax --path . # PHP syntax check
php bin/moko drift --org MokoConsulting # Scan for standards drift
php bin/moko dashboard --token $TOKEN -o dashboard.html # Client dashboard
# Code quality
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
composer check # Run all checks
```
## Architecture
| Directory | Purpose |
|---|---|
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
| `templates/` | Universal templates, configs, governance schema |
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
| `bin/moko` | Unified CLI dispatcher — `php bin/moko <command>` |
| `monitoring/sites.json` | Sites list for mcp_mokomonitor |
### CLI Framework
All CLI tools extend `MokoCli\CliFramework` (`lib/Enterprise/CliFramework.php`).
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`.
After adding a CLI tool, register it in `bin/moko` COMMAND_MAP.
### Platform Adapters
- `MokoGiteaAdapter` — git.mokoconsulting.tech (primary)
- `GitHubAdapter` — github.com mirrors
### Plugin System
Platform-specific logic in `lib/Enterprise/Plugins/`. Each implements `ProjectPluginInterface` with health checks, validation, build commands, config schemas.
## Code Quality
| Tool | Level | Config |
|---|---|---|
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
| PHPStan | Level 2 (advisory) | `phpstan.neon` |
PHPStan runs with `--memory-limit=512M`. CI enforces PHPCS errors; PHPStan is `continue-on-error`.
## Rules
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
- **Attribution**: `Authored-by: Moko Consulting`
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
- **Standards**: [MokoCli](https://git.mokoconsulting.tech/MokoConsulting/mokocli/wiki/Home)
+2 -2
View File
@@ -7,8 +7,8 @@ contact_links:
- name: 💬 Ask a Question - name: 💬 Ask a Question
url: https://mokoconsulting.tech/ url: https://mokoconsulting.tech/
about: Get help or ask questions through our website about: Get help or ask questions through our website
- name: 📚 moko-platform Documentation - name: 📚 mokocli Documentation
url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform url: https://git.mokoconsulting.tech/MokoConsulting/mokocli
about: View our coding standards and best practices about: View our coding standards and best practices
- name: 🔒 Report a Security Vulnerability - name: 🔒 Report a Security Vulnerability
url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new
+1 -1
View File
@@ -42,7 +42,7 @@ Suggested text here
<!-- Add any other context, screenshots, or references --> <!-- Add any other context, screenshots, or references -->
## Standards Alignment ## Standards Alignment
- [ ] Follows MokoStandards documentation guidelines - [ ] Follows mokocli documentation guidelines
- [ ] Uses en_US/en_GB localization - [ ] Uses en_US/en_GB localization
- [ ] Includes proper SPDX headers where applicable - [ ] Includes proper SPDX headers where applicable
+1 -1
View File
@@ -37,7 +37,7 @@ If you have ideas about how this could be implemented, share them here:
Add any other context, mockups, or screenshots about the feature request here. Add any other context, mockups, or screenshots about the feature request here.
## Relevant Standards ## Relevant Standards
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)? Does this relate to any standards in [mokocli](https://git.mokoconsulting.tech/MokoConsulting/mokocli)?
- [ ] Accessibility (WCAG 2.1 AA) - [ ] Accessibility (WCAG 2.1 AA)
- [ ] Localization (en_US/en_GB) - [ ] Localization (en_US/en_GB)
- [ ] Security best practices - [ ] Security best practices
+1 -1
View File
@@ -35,7 +35,7 @@ Use this template only for:
<!-- Describe how this could be addressed --> <!-- Describe how this could be addressed -->
## Standards Reference ## Standards Reference
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)? Does this relate to security standards in [mokocli](https://git.mokoconsulting.tech/MokoConsulting/mokocli)?
- [ ] SPDX license identifiers - [ ] SPDX license identifiers
- [ ] Secret management - [ ] Secret management
- [ ] Dependency security - [ ] Dependency security
+6 -6
View File
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/branch-protection.yml # PATH: /.gitea/workflows/branch-protection.yml
# BRIEF: Apply standardised branch protection rules to all governed repositories # BRIEF: Apply standardised branch protection rules to all governed repositories
# #
@@ -57,13 +57,13 @@ jobs:
- name: Determine target repos - name: Determine target repos
id: repos id: repos
env: env:
GA_TOKEN: ${{ secrets.GA_TOKEN }} GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: | run: |
API="${GITEA_URL}/api/v1" API="${GITEA_URL}/api/v1"
# Platform/standards/infra repos to exclude # Platform/standards/infra repos to exclude
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting" EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private mokocli MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" EXCLUDE="$EXCLUDE MokoCli-Template-Client MokoCli-Template-Dolibarr MokoCli-Template-Generic MokoCli-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then if [ -n "${{ inputs.repos }}" ]; then
# User-specified repos # User-specified repos
@@ -105,7 +105,7 @@ jobs:
- name: Apply protection rules - name: Apply protection rules
env: env:
GA_TOKEN: ${{ secrets.GA_TOKEN }} GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || 'false' }} DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: | run: |
API="${GITEA_URL}/api/v1" API="${GITEA_URL}/api/v1"
+5 -5
View File
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/bulk-repo-sync.yml # PATH: /.gitea/workflows/bulk-repo-sync.yml
# BRIEF: Bulk repo sync — runs from API repo, syncs standards to all governed repos # BRIEF: Bulk repo sync — runs from API repo, syncs standards to all governed repos
@@ -84,8 +84,8 @@ jobs:
echo "Running: php automation/bulk_sync.php ${{ steps.args.outputs.args }}" echo "Running: php automation/bulk_sync.php ${{ steps.args.outputs.args }}"
php automation/bulk_sync.php ${{ steps.args.outputs.args }} 2>&1 | tee /tmp/bulk_sync.log php automation/bulk_sync.php ${{ steps.args.outputs.args }} 2>&1 | tee /tmp/bulk_sync.log
env: env:
GA_TOKEN: ${{ secrets.GA_TOKEN }} GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }} GH_TOKEN: ${{ secrets.GH_PAT }}
GIT_PLATFORM: gitea GIT_PLATFORM: gitea
GITEA_URL: https://git.mokoconsulting.tech GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting GITEA_ORG: MokoConsulting
@@ -112,7 +112,7 @@ jobs:
bash automation/enforce_tags.sh || echo "Tag enforcement had errors (non-fatal)" bash automation/enforce_tags.sh || echo "Tag enforcement had errors (non-fatal)"
fi fi
env: env:
GA_TOKEN: ${{ secrets.GA_TOKEN }} GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: https://git.mokoconsulting.tech GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting GITEA_ORG: MokoConsulting
+3 -3
View File
@@ -2,9 +2,9 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: moko-platform.CI # DEFGROUP: mokocli.CI
# INGROUP: moko-platform # INGROUP: mokocli
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/pr-branch-check.yml # PATH: /.gitea/workflows/pr-branch-check.yml
# BRIEF: PR branch merge policy enforcement # BRIEF: PR branch merge policy enforcement
# #
+6 -6
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/renovate.yml # PATH: /.gitea/workflows/renovate.yml
# BRIEF: Run Renovate Bot across all governed repos for dependency updates # BRIEF: Run Renovate Bot across all governed repos for dependency updates
# #
@@ -57,12 +57,12 @@ jobs:
- name: Determine target repos - name: Determine target repos
id: repos id: repos
env: env:
GA_TOKEN: ${{ secrets.GA_TOKEN }} GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: | run: |
API="${GITEA_URL}/api/v1" API="${GITEA_URL}/api/v1"
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting" EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private mokocli MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" EXCLUDE="$EXCLUDE MokoCli-Template-Client MokoCli-Template-Dolibarr MokoCli-Template-Generic MokoCli-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then if [ -n "${{ inputs.repos }}" ]; then
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ') REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
@@ -107,7 +107,7 @@ jobs:
- name: Run Renovate - name: Run Renovate
if: steps.repos.outputs.repo_list != '' if: steps.repos.outputs.repo_list != ''
env: env:
RENOVATE_TOKEN: ${{ secrets.GA_TOKEN }} RENOVATE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
RENOVATE_PLATFORM: gitea RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: ${{ env.GITEA_URL }}/api/v1 RENOVATE_ENDPOINT: ${{ env.GITEA_URL }}/api/v1
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@mokoconsulting.tech>' RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@mokoconsulting.tech>'
+3 -3
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance # INGROUP: mokocli.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/sync-wikis.yml # PATH: /.gitea/workflows/sync-wikis.yml
# BRIEF: Daily sync of all Gitea wikis to consolidated GitHub wiki repo # BRIEF: Daily sync of all Gitea wikis to consolidated GitHub wiki repo
@@ -31,7 +31,7 @@ jobs:
- name: Sync all wikis - name: Sync all wikis
env: env:
GH_TOKEN: ${{ secrets.GH_TOKEN }} GH_TOKEN: ${{ secrets.GH_PAT }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: | run: |
if [ -z "$GH_TOKEN" ]; then if [ -z "$GH_TOKEN" ]; then
+9 -9
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release # INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml # PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00 # VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) # BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
@@ -43,19 +43,19 @@ jobs:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1 fetch-depth: 1
- name: Setup moko-platform tools - name: Setup mokocli tools
run: | run: |
if ! command -v composer &> /dev/null; then 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 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 fi
if [ -d "/opt/moko-platform/cli" ]; then if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV" echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else else
git clone --depth 1 --branch main --quiet \ git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \ "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/moko-platform-api /tmp/mokocli
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV" echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi fi
- name: Bump version - name: Bump version
+421 -270
View File
@@ -1,270 +1,421 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release # INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template # PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00 # VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml # BRIEF: Universal build & release detects platform from manifest.xml
# #
# +========================================================================+ # +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE | # | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+ # +========================================================================+
# | | # | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | | # | |
# | Platform-specific: | # | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages | # | joomla: XML manifest, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset | # | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream | # | generic: README-only, no update stream |
# | | # | |
# +========================================================================+ # +========================================================================+
name: "Universal: Build & Release" name: "Universal: Build & Release"
on: on:
pull_request: pull_request:
types: [opened, closed] types: [opened, closed]
branches: branches:
- main - main
workflow_dispatch: workflow_dispatch:
inputs: inputs:
action: action:
description: 'Action to perform' description: 'Action to perform'
required: false required: false
type: choice type: choice
default: release default: release
options: options:
- release - release
- promote-rc - promote-rc
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions: permissions:
contents: write contents: write
jobs: jobs:
# ── PR Opened → Rename branch to RC and build RC release ───────────────────── # ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc: promote-rc:
name: Promote to RC name: Promote to RC
runs-on: release runs-on: release
if: >- if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) || (github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1 fetch-depth: 1
- name: Setup moko-platform tools - name: Setup mokocli tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: | run: |
if ! command -v composer &> /dev/null; then if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; 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 echo Using pre-installed /opt/mokocli
fi echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
# Always fetch latest CLI tools — never use stale cache from previous runs else
rm -rf /tmp/moko-platform-api echo Falling back to fresh clone
git clone --depth 1 --branch main --quiet \ if ! command -v composer > /dev/null 2>&1; then
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ 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
/tmp/moko-platform-api fi
cd /tmp/moko-platform-api rm -rf /tmp/mokocli
composer install --no-dev --no-interaction --quiet 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
- name: Rename branch to rc cd /tmp/mokocli
run: | composer install --no-dev --no-interaction --quiet
php /tmp/moko-platform-api/cli/branch_rename.php \ echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ fi
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ - name: Rename branch to rc
--pr "${{ github.event.pull_request.number }}" run: |
php ${MOKO_CLI}/branch_rename.php \
- name: Checkout rc and configure git --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
run: | --token "${{ secrets.MOKOGITEA_TOKEN }}" \
git fetch origin rc --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
git checkout rc --pr "${{ github.event.pull_request.number }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]" - name: Checkout rc and configure git
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" run: |
git fetch origin rc
- name: Publish RC release git checkout rc
run: | git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
php /tmp/moko-platform-api/cli/release_publish.php \ git config --local user.name "gitea-actions[bot]"
--path . --stability rc --bump minor --branch rc \ git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Publish RC release
- name: Summary run: |
if: always() php ${MOKO_CLI}/release_publish.php \
run: | --path . --stability rc --bump minor --branch rc \
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY --token "${{ secrets.MOKOGITEA_TOKEN }}"
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
- name: Update RC release notes from CHANGELOG.md
# ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── run: |
release: API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
name: Build & Release Pipeline TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
runs-on: release
if: >- # Extract [Unreleased] section from changelog
github.event.pull_request.merged == true || NOTES=""
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
steps: fi
- name: Checkout repository [ -z "$NOTES" ] && NOTES="Release candidate"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: # Find the RC release and update its body
token: ${{ secrets.MOKOGITEA_TOKEN }} RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
fetch-depth: 0 "${API_BASE}/releases/tags/release-candidate" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
- name: Configure git for bot pushes
run: | if [ -n "$RELEASE_ID" ]; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" python3 -c "
git config --local user.name "gitea-actions[bot]" import json, urllib.request
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
- name: Setup moko-platform tools req = urllib.request.Request(
env: '${API_BASE}/releases/${RELEASE_ID}',
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} data=payload, method='PATCH',
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting headers={
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' 'Authorization': 'token ${TOKEN}',
run: | 'Content-Type': 'application/json'
# Ensure PHP + Composer are available })
if ! command -v composer &> /dev/null; then urllib.request.urlopen(req)
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 " <<< "$NOTES"
fi echo "RC release notes updated from CHANGELOG.md"
# Always fetch latest CLI tools — never use stale cache from previous runs fi
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \ - name: Summary
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ if: always()
/tmp/moko-platform-api run: |
cd /tmp/moko-platform-api echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
composer install --no-dev --no-interaction --quiet echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
- name: "Publish stable release" release:
run: | name: Build & Release Pipeline
php /tmp/moko-platform-api/cli/release_publish.php \ runs-on: release
--path . --stability stable --bump minor --branch main \ if: >-
--token "${{ secrets.MOKOGITEA_TOKEN }}" github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub" steps:
if: >- - name: Checkout repository
steps.version.outputs.skip != 'true' && uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
secrets.GH_MIRROR_TOKEN != '' with:
continue-on-error: true token: ${{ secrets.MOKOGITEA_TOKEN }}
run: | fetch-depth: 0
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}" - name: Configure git for bot pushes
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
php /tmp/moko-platform-api/cli/release_mirror.php \ git config --local user.name "gitea-actions[bot]"
--version "$VERSION" --tag "$RELEASE_TAG" \ git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \ - name: Check for merge conflict markers
--branch main 2>&1 || true run: |
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY 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
# -- STEP 10: Sync main branch to GitHub mirror ---------------------------- echo "::error::Merge conflict markers found — aborting release"
- name: "Step 10: Push main to GitHub mirror" echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
if: >- echo '```' >> $GITHUB_STEP_SUMMARY
steps.version.outputs.skip != 'true' && echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
secrets.GH_MIRROR_TOKEN != '' echo '```' >> $GITHUB_STEP_SUMMARY
continue-on-error: true exit 1
run: | fi
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" echo "No conflict markers found"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) - name: Setup mokocli tools
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ env:
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
git fetch origin main --depth=1 MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
git push github origin/main:refs/heads/main --force 2>/dev/null \ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
&& echo "main branch pushed to GitHub mirror" \ run: |
|| echo "WARNING: GitHub mirror push failed" if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
- name: "Step 11: Delete rc branch and recreate dev from main" echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
if: steps.version.outputs.skip != 'true' else
continue-on-error: true echo Falling back to fresh clone
run: | if ! command -v composer > /dev/null 2>&1; then
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" 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
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" fi
rm -rf /tmp/mokocli
# Delete rc branch (ephemeral — created by promote-rc) CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
"${API_BASE}/branches/rc" 2>/dev/null \ cd /tmp/mokocli
&& echo "Deleted rc branch" || echo "rc branch not found" composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
# Delete dev branch fi
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" - name: "Detect platform"
id: platform
# Recreate dev from main (now includes version bump + changelog promotion) run: |
curl -sf -X POST -H "Authorization: token ${TOKEN}" \ php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
-H "Content-Type: application/json" \ php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" - name: "Determine version bump level"
id: bump
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY run: |
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
- name: "Step 12: Create version branch from main" # Feature/dev branches: bump minor for the new stable release
if: steps.version.outputs.skip != 'true' HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
continue-on-error: true case "$HEAD_REF" in
run: | fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" *) BUMP="minor" ;;
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" esac
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
BRANCH_NAME="version/${VERSION}" echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
MAIN_SHA=$(git rev-parse HEAD)
- name: "Publish stable release"
# Delete old version branch if it exists (same version re-release) run: |
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}" BUMP_FLAG=""
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
# Create version/XX.YY.ZZ from main BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
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" fi
php ${MOKO_CLI}/release_publish.php \
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY --path . --stability stable ${BUMP_FLAG} --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: "Read published version"
# -- Dolibarr post-release: Reset dev version ----------------------------- id: version
- name: "Post-release: Reset dev version" run: |
if: steps.version.outputs.skip != 'true' VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
continue-on-error: true VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
run: | [ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
php /tmp/moko-platform-api/cli/version_reset_dev.php \ echo "tag=stable" >> "$GITHUB_OUTPUT"
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ echo "release_tag=stable" >> "$GITHUB_OUTPUT"
--branch dev --path . 2>&1 || true echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}"
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary - name: Update release notes and promote changelog
if: always() run: |
run: | API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then # Get the stable release info (version and ID)
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY "${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY # Extract version from release name (e.g. "06.17.00" or "v06.17.00")
else VERSION=$(python3 -c "
echo "" >> $GITHUB_STEP_SUMMARY import json, sys, re
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY r = json.load(sys.stdin)
echo "" >> $GITHUB_STEP_SUMMARY name = r.get('name', '')
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY m = re.search(r'(\d+\.\d+\.\d+)', name)
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY print(m.group(1) if m else '')
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY " <<< "$RELEASE_JSON" 2>/dev/null || true)
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY # Extract [Unreleased] section from changelog
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY NOTES=""
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY if [ -f "CHANGELOG.md" ]; then
fi 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
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal # INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/branch-cleanup.yml # PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge # BRIEF: Delete feature branches after PR merge
+7 -210
View File
@@ -1,213 +1,10 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # 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
# SPDX-License-Identifier: GPL-3.0-or-later # have different version numbers in templateDetails.xml / manifest.xml.
# name: "Cascade Main → Dev (DISABLED)"
# FILE INFORMATION on: workflow_dispatch
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/cascade-dev.yml.template
# VERSION: 02.00.00
# BRIEF: Forward-merge main → all open branches after every push to main
#
# +========================================================================+
# | CASCADE MAIN → ALL BRANCHES |
# +========================================================================+
# | |
# | Triggers on every push to main (PR merges, bot commits, etc.) |
# | |
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
# | 2. For each: create PR (main → branch), auto-merge if clean |
# | 3. On conflict: leave PR open for manual resolution |
# | |
# +========================================================================+
name: "Universal: Cascade Main → Dev"
on:
push:
branches:
- main
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
pull-requests: write
jobs: jobs:
cascade: noop:
name: Cascade main → branches
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip cascade]')
steps: steps:
- name: Discover target branches - run: echo "Cascade disabled — auto-release handles dev recreation"
id: branches
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Fetch all branches (paginated)
PAGE=1
ALL_BRANCHES=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
PAGE=$((PAGE + 1))
done
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
TARGETS=""
for BRANCH in $ALL_BRANCHES; do
case "$BRANCH" in
dev|dev/*|rc/*|beta/*|alpha/*)
TARGETS="$TARGETS $BRANCH"
;;
esac
done
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
if [ -z "$TARGETS" ]; then
echo "targets=" >> "$GITHUB_OUTPUT"
echo "️ No cascade target branches found"
else
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$TARGETS" | wc -w)
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
fi
- name: Cascade to all target branches
if: steps.branches.outputs.targets != ''
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
SHORT_SHA="${GITHUB_SHA:0:7}"
TARGETS="${{ steps.branches.outputs.targets }}"
SUCCESS=0
CONFLICTS=0
SKIPPED=0
FAILED=0
for BRANCH in $TARGETS; do
echo ""
echo "═══ main → ${BRANCH} ═══"
# Check if branch is already up to date
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
RESPONSE=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/compare/${ENCODED_BRANCH}...main")
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
if [ "$AHEAD" -eq 0 ]; then
echo " ✅ Already up to date"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo " ️ main is ${AHEAD} commit(s) ahead"
# Check for existing cascade PR
EXISTING=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
PR_NUMBER=""
if [ "$EXISTING_COUNT" -gt 0 ]; then
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
echo " ️ Reusing existing PR #${PR_NUMBER}"
else
# Create cascade PR
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
\"head\": \"main\",
\"base\": \"${BRANCH}\"
}" \
"${API}/pulls")
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
BODY=$(echo "$PR_RESPONSE" | sed '$d')
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
FAILED=$((FAILED + 1))
continue
fi
echo " ✅ Created PR #${PR_NUMBER}"
fi
# Try auto-merge
PR_DATA=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/pulls/${PR_NUMBER}")
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
if [ "$MERGEABLE" != "true" ]; then
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
continue
fi
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"Do\": \"merge\",
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
\"delete_branch_after_merge\": false
}" \
"${API}/pulls/${PR_NUMBER}/merge")
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
echo " ✅ Merged — ${BRANCH} is in sync"
SUCCESS=$((SUCCESS + 1))
else
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
fi
done
# Summary
echo ""
echo "════════════════════════════════════════"
echo " ✅ Merged: ${SUCCESS}"
echo " ⚠️ Conflicts: ${CONFLICTS}"
echo " ⏭️ Up to date: ${SKIPPED}"
echo " ❌ Failed: ${FAILED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
exit 1
fi
+191
View File
@@ -0,0 +1,191 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
name: "Generic: Project CI"
on:
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Lint & Validate ───────────────────────────────────────────────────
lint:
name: Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
php -v
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install PHP dependencies
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
fi
- name: Install Node.js dependencies
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "package.json" ]; then
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
fi
- name: PHP syntax check
if: steps.detect.outputs.has_php == 'true'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -eq 0 ]; then
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
else
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: TypeScript/JavaScript lint
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "node_modules/.bin/eslint" ]; then
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
echo "::warning::ESLint config found but eslint not installed"
else
echo "No ESLint configured — skipping"
fi
- name: TypeScript compile check
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
fi
- name: PHPStan static analysis
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
fi
# ── Tests ─────────────────────────────────────────────────────────────
test:
name: Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
- name: Run PHP tests
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "vendor/bin/phpunit" ]; then
vendor/bin/phpunit --testdox 2>&1
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
echo "::warning::PHPUnit config found but phpunit not installed"
else
echo "No PHPUnit configured — skipping"
fi
- name: Run Node.js tests
if: steps.detect.outputs.has_node == 'true'
run: |
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
npm test 2>&1
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
else
echo "No test script in package.json — skipping"
fi
- name: Build check
run: |
if [ -f "Makefile" ]; then
make build 2>&1 || echo "::warning::Build failed or not configured"
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
npm run build 2>&1 || echo "::warning::Build failed"
fi
+62 -13
View File
@@ -4,18 +4,18 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI # INGROUP: mokocli.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/ci-platform.yml # PATH: /.mokogitea/workflows/ci-platform.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: moko-platform CI — the standards engine validates itself # BRIEF: mokocli CI — the standards engine validates itself
# #
# +========================================================================+ # +========================================================================+
# | MOKOSTANDARDS PLATFORM CI | # | MOKO-PLATFORM CI |
# +========================================================================+ # +========================================================================+
# | | # | |
# | This is NOT a generic CI workflow. This is the self-validation | # | This is NOT a generic CI workflow. This is the self-validation |
# | pipeline for the central moko-platform enterprise engine. | # | pipeline for the central mokocli enterprise engine. |
# | | # | |
# | It dogfoods every tool the platform ships to governed repos: | # | It dogfoods every tool the platform ships to governed repos: |
# | | # | |
@@ -29,7 +29,7 @@
# | | # | |
# +========================================================================+ # +========================================================================+
name: "Platform: moko-platform CI" name: "Platform: mokocli CI"
on: on:
push: push:
@@ -41,7 +41,7 @@ on:
paths-ignore: paths-ignore:
- '**.md' - '**.md'
- 'wiki/**' - 'wiki/**'
- '.gitea/ISSUE_TEMPLATE/**' - '.mokogitea/ISSUE_TEMPLATE/**'
pull_request: pull_request:
branches: branches:
- main - main
@@ -104,7 +104,7 @@ jobs:
echo "::error file=${file}::PHP syntax error" echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1)) ERRORS=$((ERRORS + 1))
fi fi
done < <(find lib/ validate/ automation/ cli/ src/ deploy/ -name "*.php" -print0 2>/dev/null) done < <(find lib/ validate/ automation/ cli/ source/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
{ {
echo "### PHP Syntax" echo "### PHP Syntax"
@@ -270,7 +270,7 @@ jobs:
echo "::warning file=${file}::Missing SPDX header" echo "::warning file=${file}::Missing SPDX header"
MISSING=$((MISSING + 1)) MISSING=$((MISSING + 1))
fi fi
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null) done < <(find lib/ validate/ cli/ source/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
{ {
echo "### License Headers" echo "### License Headers"
@@ -289,7 +289,7 @@ jobs:
echo "::error file=${file}::Potential hardcoded secret detected" echo "::error file=${file}::Potential hardcoded secret detected"
FOUND=$((FOUND + 1)) FOUND=$((FOUND + 1))
fi fi
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null) done < <(find lib/ validate/ cli/ source/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
{ {
echo "### Secret Detection" echo "### Secret Detection"
@@ -412,10 +412,16 @@ jobs:
if: always() if: always()
steps: steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: Check gate results - name: Check gate results
run: | run: |
{ {
echo "# moko-platform CI" echo "# mokocli CI"
echo "" echo ""
echo "| Gate | Job | Status |" echo "| Gate | Job | Status |"
echo "|---|---|---|" echo "|---|---|---|"
@@ -437,3 +443,46 @@ jobs:
echo "::error::One or more CI gates failed" echo "::error::One or more CI gates failed"
exit 1 exit 1
fi fi
- name: "File issues for failed gates"
if: >-
always() &&
(needs.code-quality.result == 'failure' ||
needs.tests.result == 'failure' ||
needs.self-health.result == 'failure' ||
needs.governance.result == 'failure' ||
needs.templates.result == 'failure')
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="Platform CI"
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 "Code Quality" \
"${{ needs.code-quality.result }}" \
"PHPCS (PSR-12), PHPStan, or PHP syntax checks failed. Run \`composer check\` locally to reproduce."
report_gate "Unit Tests" \
"${{ needs.tests.result }}" \
"PHPUnit tests failed on one or more PHP versions (8.1, 8.2, 8.3). Run \`vendor/bin/phpunit --testdox\` locally."
report_gate "Self-Health" \
"${{ needs.self-health.result }}" \
"Self-health score fell below the 80% threshold. Run \`php bin/moko health -- --path .\` locally."
report_gate "Governance" \
"${{ needs.governance.result }}" \
"Governance checks failed (license headers, secrets, or version consistency). Check the CI run summary for specifics."
report_gate "Template Integrity" \
"${{ needs.templates.result }}" \
"Workflow or gitignore templates failed YAML validation or are missing required entries."
+9 -9
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance # INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/cleanup.yml # PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
@@ -33,17 +33,17 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.GA_TOKEN }}
- name: Delete merged branches - name: Delete merged branches
env: env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: | run: |
echo "=== Merged Branch Cleanup ===" echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API # List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \ BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name') "${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0 DELETED=0
@@ -56,7 +56,7 @@ jobs:
# Check if branch is merged into main # Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}" echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true "${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1)) DELETED=$((DELETED + 1))
fi fi
@@ -66,20 +66,20 @@ jobs:
- name: Clean old workflow runs - name: Clean old workflow runs
env: env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: | run: |
echo "=== Workflow Run Cleanup ===" echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" 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) 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 # Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \ RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \ "${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null) jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0 DELETED=0
for RUN_ID in $RUNS; do for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1)) DELETED=$((DELETED + 1))
done done
+76
View File
@@ -0,0 +1,76 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
name: "Publish to Composer"
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
release:
types: [published]
workflow_dispatch:
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
publish:
name: Publish Package
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip publish]')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --quiet
- name: Determine version
id: version
run: |
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Package version: ${VERSION}"
# Gitea Composer Registry — auto-publishes from tags
# The tag push itself registers the package at:
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
- name: Verify Gitea registry
run: |
echo "Gitea Composer registry auto-publishes from tags."
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version
- name: Notify Packagist
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
+126
View File
@@ -0,0 +1,126 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+2 -6
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security # INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template # PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
@@ -25,10 +25,6 @@
name: "Universal: Secret Scanning" name: "Universal: Secret Scanning"
on: on:
pull_request:
branches:
- main
- 'dev/**'
schedule: schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC - cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch: workflow_dispatch:
+3 -3
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: mokocli.Automation
# VERSION: 09.21.00 # VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
@@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Create branch and comment - name: Create branch and comment
run: | run: |
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}" ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}" ISSUE_TITLE="${{ github.event.issue.title }}"
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Notifications # INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/notify.yml # PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure # BRIEF: Push notifications via ntfy on release success or workflow failure
+534 -236
View File
@@ -1,236 +1,534 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI # INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template # PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00 # VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge # BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check" name: "Universal: PR Check"
on: on:
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened, edited]
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
# ── Branch Policy ────────────────────────────────────────────────────── # ── Branch Policy ──────────────────────────────────────────────────────
branch-policy: branch-policy:
name: Branch Policy name: Branch Policy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check branch merge target - name: Check branch merge target
run: | run: |
HEAD="${{ github.head_ref }}" HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}" BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}" echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true ALLOWED=true
REASON="" REASON=""
case "$HEAD" in case "$HEAD" in
feature/*|feat/*) feature/*|feat/*)
if [ "$BASE" != "dev" ]; then if [ "$BASE" != "dev" ]; then
ALLOWED=false ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'" REASON="Feature branches must target 'dev', not '${BASE}'"
fi fi
;; ;;
fix/*|bugfix/*) fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then if [ "$BASE" != "dev" ]; then
ALLOWED=false ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'" REASON="Fix branches must target 'dev', not '${BASE}'"
fi fi
;; ;;
patch/*) patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'" REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi fi
;; ;;
hotfix/*) hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi fi
;; ;;
rc) rc)
if [ "$BASE" != "main" ]; then if [ "$BASE" != "main" ]; then
ALLOWED=false ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'" REASON="RC branch can only merge into 'main', not '${BASE}'"
fi fi
;; ;;
dev) dev)
if [ "$BASE" != "main" ]; then if [ "$BASE" != "main" ]; then
ALLOWED=false ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'" REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi fi
;; ;;
esac esac
if [ "$ALLOWED" = false ]; then if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}" echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1 exit 1
fi fi
echo "Branch policy: OK (${HEAD} → ${BASE})" echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ──────────────────────────────────────────────────── # ── Secret Scanning ──────────────────────────────────────────────────
validate: gitleaks:
name: Validate PR name: Secret Scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
steps: - name: Checkout
- name: Checkout uses: actions/checkout@v4
uses: actions/checkout@v4 with:
fetch-depth: 0
- name: Detect platform
id: platform - name: Install Gitleaks
run: | run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback GITLEAKS_VERSION="8.21.2"
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') | tar -xz -C /usr/local/bin gitleaks
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" - name: Scan PR commits for secrets
run: |
- name: Setup PHP if gitleaks detect --source . --verbose \
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
run: | echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
if ! command -v php &> /dev/null; then else
sudo apt-get update -qq echo "::error::Potential secrets detected in PR commits"
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1 exit 1
fi fi
- name: PHP syntax check # ── Code Validation ────────────────────────────────────────────────────
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' validate:
run: | name: Validate PR
ERRORS=0 runs-on: ubuntu-latest
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then steps:
ERRORS=$((ERRORS + 1)) - name: Checkout
fi uses: actions/checkout@v4
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)" - name: Check for merge conflict markers
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; } 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)
- name: Validate platform manifest if [ -n "$CONFLICTS" ]; then
run: | echo "::error::Merge conflict markers found in source files"
PLATFORM="${{ steps.platform.outputs.platform }}" echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
case "$PLATFORM" in echo '```' >> $GITHUB_STEP_SUMMARY
joomla) echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1) echo '```' >> $GITHUB_STEP_SUMMARY
if [ -z "$MANIFEST" ]; then exit 1
echo "::warning::No Joomla manifest found (WaaS site)" fi
exit 0 echo "No conflict markers found"
fi
echo "Manifest: ${MANIFEST}" - name: Detect platform
if command -v php &> /dev/null; then id: platform
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; } run: |
fi # Read platform from XML manifest (<platform> tag) or plain text fallback
for ELEMENT in name version description; do PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; } [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
done [ -z "$PLATFORM" ] && PLATFORM="generic"
echo "Joomla manifest valid" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
;;
dolibarr) - name: Setup PHP
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
if [ -z "$MOD_FILE" ]; then run: |
echo "::error::No mod*.class.php found" if ! command -v php &> /dev/null; then
exit 1 sudo apt-get update -qq
fi sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
echo "Dolibarr module: ${MOD_FILE}" fi
;;
*) - name: PHP syntax check
echo "Generic platform — no manifest validation" if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
;; run: |
esac ERRORS=0
while IFS= read -r -d '' file; do
- name: Check update stream format if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
run: | ERRORS=$((ERRORS + 1))
PLATFORM="${{ steps.platform.outputs.platform }}" fi
case "$PLATFORM" in done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
joomla) echo "PHP lint: ${ERRORS} error(s)"
if [ -f "updates.xml" ]; then [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
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; } - name: Joomla JEXEC guard check
fi if: steps.platform.outputs.platform == 'joomla'
echo "updates.xml valid" run: |
fi ERRORS=0
;; while IFS= read -r -d '' file; do
dolibarr) # Skip vendor, node_modules, and index.html stub files
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt" case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
;; # Check first 10 lines for JEXEC or JPATH guard
esac if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
echo "::error file=${file}::Missing JEXEC guard: ${file}"
- name: Check changelog has unreleased entry ERRORS=$((ERRORS + 1))
run: | fi
if [ ! -f "CHANGELOG.md" ]; then done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "::warning::No CHANGELOG.md found" if [ "$ERRORS" -gt 0 ]; then
exit 0 echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
fi echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
# Check for content under [Unreleased] section echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then exit 1
echo "::error::CHANGELOG.md missing [Unreleased] section" fi
exit 1 echo "JEXEC guard: OK"
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased - name: Joomla directory listing protection
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true) if: steps.platform.outputs.platform == 'joomla'
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then run: |
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes." MISSING=0
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY SOURCE_DIR="src"
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY [ ! -d "$SOURCE_DIR" ] && exit 0
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY while IFS= read -r dir; do
exit 1 if [ ! -f "${dir}/index.html" ]; then
fi echo "::warning::Missing index.html in ${dir} (directory listing protection)"
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]" MISSING=$((MISSING + 1))
fi
- name: Verify package source done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
run: | if [ "$MISSING" -gt 0 ]; then
SOURCE_DIR="src" echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
if [ ! -d "$SOURCE_DIR" ]; then fi
echo "::warning::No src/ or htdocs/ directory" echo "Directory protection: ${MISSING} missing (advisory)"
exit 0
fi - name: Joomla script file and asset checks
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l) if: steps.platform.outputs.platform == 'joomla'
echo "Source: ${FILE_COUNT} files" run: |
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } ERRORS=0
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
# ── Pre-Release RC Build ───────────────────────────────────────────────── [ -z "$MANIFEST" ] && exit 0
pre-release: MANIFEST_DIR=$(dirname "$MANIFEST")
name: Build RC Package
runs-on: ubuntu-latest # Check scriptfile exists if declared
needs: [branch-policy, validate] SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
if [ -n "$SCRIPTFILE" ]; then
steps: if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
- name: Trigger RC pre-release echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
env: ERRORS=$((ERRORS + 1))
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} else
REPO: ${{ github.repository }} echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
BRANCH: ${{ github.head_ref }} fi
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} fi
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\"}}" # Require joomla.asset.json and validate it
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY 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 '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
# 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."
+94 -75
View File
@@ -4,19 +4,26 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release # INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/pre-release.yml.template # PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00 # VERSION: 05.01.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch # BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
name: "Universal: Pre-Release" name: "Universal: Pre-Release"
on: on:
pull_request: push:
types: [closed]
branches: branches:
- dev - dev
- 'fix/**'
- 'patch/**'
- 'hotfix/**'
- 'bugfix/**'
- 'chore/**'
- alpha
- beta
- rc
workflow_dispatch: workflow_dispatch:
inputs: inputs:
stability: stability:
@@ -39,11 +46,11 @@ env:
jobs: jobs:
build: build:
name: "Build Pre-Release (${{ inputs.stability || 'development' }})" name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
runs-on: release runs-on: release
if: >- if: >-
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') github.event_name == 'push'
steps: steps:
- name: Checkout - name: Checkout
@@ -51,32 +58,50 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
- name: Setup moko-platform tools - name: Setup mokocli tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: | run: |
if ! command -v composer &> /dev/null; then # Use pre-installed /opt/mokocli if available (updated by cron every 6h)
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 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 fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
- name: Detect platform - name: Detect platform
id: platform id: platform
run: | 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 php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve metadata and bump version - name: Resolve metadata and bump version
id: meta id: meta
run: | run: |
STABILITY="${{ inputs.stability || 'development' }}" # 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 case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;; development) SUFFIX="-dev"; TAG="development" ;;
@@ -85,20 +110,26 @@ jobs:
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac esac
# Read current version (bump already handled by push workflow) # Bump version via CLI: patch for dev/alpha/beta, minor for RC
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null) case "$STABILITY" in
[ -z "$VERSION" ] && VERSION="00.00.01" release-candidate) BUMP="minor" ;;
*) BUMP="patch" ;;
esac
# Strip any existing suffix from version before applying stability 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\)$//') VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \ php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true --path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
# Verify version consistency across all files
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Update VERSION variable with suffix # 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 if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}" VERSION="${VERSION}${SUFFIX}"
fi fi
@@ -142,7 +173,42 @@ jobs:
php ${MOKO_CLI}/release_create.php \ php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \ --path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease --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 - name: Build package and upload
id: package id: package
@@ -155,55 +221,8 @@ jobs:
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true --repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml # updates.xml is generated dynamically by MokoGitea license server
if: steps.platform.outputs.platform == 'joomla' # No need to build, commit, or sync updates.xml from workflows
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml -- skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
if: steps.platform.outputs.platform == 'joomla'
run: |
CURRENT_BRANCH="${{ github.ref_name }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml -> ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
- name: "Delete lesser pre-release channels (cascade)" - name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true continue-on-error: true
+66
View File
@@ -0,0 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
name: "RC Revert"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
revert:
name: Rename rc/ back to dev/
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
File diff suppressed because it is too large Load Diff
+2 -18
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security # INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml # PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages # BRIEF: Dependency vulnerability scanning for composer and npm packages
@@ -80,19 +80,3 @@ jobs:
-H "Priority: high" \ -H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \ -d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true "${NTFY_URL}/${NTFY_TOPIC}" || true
- name: Joomla version audit
if: always()
run: |
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
echo "$JOOMLA_SITES" > /tmp/sites.json
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
rm -f /tmp/sites.json
else
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
fi
env:
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
@@ -0,0 +1,103 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/sync-feature-versions.yml
# VERSION: 01.00.00
# BRIEF: Merge dev into open feature branches after version bumps
name: "Universal: Sync Feature Branch Versions"
on:
push:
branches:
- dev
workflow_dispatch:
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
sync:
name: Sync feature branches with dev
runs-on: ubuntu-latest
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, 'chore(version)')
steps:
- name: Checkout dev
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: dev
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Configure git
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: Merge dev into feature branches
run: |
echo "=== Syncing feature branches with dev ==="
# Fetch all remote branches
git fetch origin
# Find feature branches (feature/*, fix/*, patch/*, hotfix/*, bugfix/*, chore/*)
BRANCHES=$(git branch -r --list 'origin/feature/*' 'origin/fix/*' 'origin/patch/*' 'origin/hotfix/*' 'origin/bugfix/*' 'origin/chore/*' | sed 's|origin/||; s/^[[:space:]]*//')
if [ -z "$BRANCHES" ]; then
echo "No feature branches found — nothing to sync"
exit 0
fi
SYNCED=0
SKIPPED=0
FAILED=0
for BRANCH in $BRANCHES; do
echo ""
echo "--- ${BRANCH} ---"
# Skip branches that are already up to date with dev
if git merge-base --is-ancestor dev "origin/${BRANCH}" 2>/dev/null; then
echo "Already up to date"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Try to merge dev into the branch
git checkout "origin/${BRANCH}" -B "$BRANCH" 2>/dev/null
if git merge dev --no-edit -m "chore: merge dev into ${BRANCH} (version sync) [skip ci]" 2>/dev/null; then
git push origin "$BRANCH" 2>/dev/null
echo "Synced successfully"
SYNCED=$((SYNCED + 1))
else
git merge --abort 2>/dev/null || true
echo "Merge conflict — skipping (manual rebase needed)"
FAILED=$((FAILED + 1))
fi
done
# Return to dev
git checkout dev 2>/dev/null || true
echo ""
echo "=== Summary ==="
echo "Synced: ${SYNCED}"
echo "Already current: ${SKIPPED}"
echo "Conflicts (skipped): ${FAILED}"
if [ "$FAILED" -gt 0 ]; then
echo "::warning::${FAILED} branch(es) had merge conflicts and need manual rebase"
fi
-312
View File
@@ -1,312 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/update-server.yml
# VERSION: 05.00.00
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
#
# Thin wrapper around moko-platform CLI tools.
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
#
# Joomla filters update entries by the user's "Minimum Stability" setting.
name: "Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update Server
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || true
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
- name: Detect platform
id: platform
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve stability and bump version
id: meta
run: |
BRANCH="${{ github.ref_name }}"
# Configure git for bot pushes
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"
# Auto-bump patch version
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Strip any existing suffix before applying stability
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
# Determine stability from branch or manual input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
else
STABILITY="development"
fi
# Version suffix per stability stream
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
rc) SUFFIX="-rc"; TAG="release-candidate" ;;
*) SUFFIX=""; TAG="stable" ;;
esac
# Propagate version with stability suffix to all manifest files
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Re-read version (now includes suffix from version_set_platform)
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "display_version=${VERSION}" >> "$GITHUB_OUTPUT"
# Commit version bump if changed
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
- name: Create release and upload package
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Create or update Gitea release
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
# Build package and upload
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push updates.xml
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push
}
- name: Sync updates.xml to main
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
python3 -c "
import base64, json, urllib.request, sys
with open('updates.xml', 'rb') as f:
content = base64.b64encode(f.read()).decode()
payload = json.dumps({
'content': content,
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
'branch': 'main'
}).encode()
req = urllib.request.Request(
'${API_BASE}/contents/updates.xml',
data=payload, method='PUT',
headers={
'Authorization': 'token ${GITEA_TOKEN}',
'Content-Type': 'application/json'
})
try:
urllib.request.urlopen(req)
print('updates.xml synced to main')
except Exception as e:
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
"
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# Permission check: admin or maintain role required
ACTOR="${{ github.actor }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
DISPLAY="${{ steps.meta.outputs.display_version }}"
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
# VERSION: 01.01.00
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
name: "Universal: Workflow Sync Trigger"
on:
pull_request:
types: [closed]
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
steps:
- name: Determine platform from repo name
id: platform
run: |
REPO="${{ github.event.repository.name }}"
case "$REPO" in
Template-Joomla) PLATFORM="joomla" ;;
Template-Dolibarr) PLATFORM="dolibarr" ;;
Template-Go) PLATFORM="go" ;;
Template-MCP) PLATFORM="mcp" ;;
Template-Generic) PLATFORM="" ;;
*) PLATFORM="" ;;
esac
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}"
- name: Clone mokocli
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install dependencies
run: |
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- name: Run workflow sync
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
ARGS="--token ${MOKOGITEA_TOKEN}"
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
ARGS="${ARGS} --phase repos"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ -n "$PLATFORM" ]; then
ARGS="${ARGS} --platform-filter ${PLATFORM}"
fi
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"metadata": { "metadata": {
"generated_at": "2026-03-10T19:51:42.238134Z", "generated_at": "2026-03-10T19:51:42.238134Z",
"repository": "mokoconsulting-tech/MokoStandards", "repository": "MokoConsulting/mokocli",
"version": "1.0.0" "version": "1.0.0"
}, },
"scripts": [ "scripts": [
+10 -8
View File
@@ -2,9 +2,9 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoStandards.Root DEFGROUP: MokoCli.Root
INGROUP: MokoStandards INGROUP: MokoCli
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
PATH: /CHANGELOG.md PATH: /CHANGELOG.md
BRIEF: Release changelog BRIEF: Release changelog
--> -->
@@ -12,12 +12,14 @@ BRIEF: Release changelog
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
## [09.21.00] --- 2026-05-30 ## [09.38.00] --- 2026-06-21
## [09.20.00] --- 2026-05-30 ## [09.38.00] --- 2026-06-21
## [09.19.00] --- 2026-05-30 ## [09.37.00] --- 2026-06-21
## [09.18.00] --- 2026-05-30 ## [09.37.00] --- 2026-06-21
## [09.17.00] --- 2026-05-30 ## [09.36.00] --- 2026-06-21
## [09.36.00] --- 2026-06-21
-102
View File
@@ -1,102 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with this repository.
## Project Overview
**moko-platform** — Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
| Field | Value |
|---|---|
| **Language** | PHP 8.1+ |
| **Default branch** | main |
| **License** | GPL-3.0-or-later |
| **Version** | 09.01.00 |
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
## Common Commands
```bash
composer install # Install PHP dependencies
php bin/moko health --path . # Run repo health check
php bin/moko check:syntax --path . # PHP syntax check
php bin/moko drift --org MokoConsulting # Scan for standards drift
php bin/moko dashboard --token $TOKEN -o dashboard.html # Generate client dashboard
# Code quality
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
# Run all checks
composer check
```
## Architecture
### Directory Layout
| Directory | Purpose |
|-----------|---------|
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
| `templates/` | Universal templates, configs, governance schema |
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
### CLI Framework
All CLI tools extend `MokoEnterprise\CliFramework` (defined in `lib/Enterprise/CliFramework.php`).
Pattern for new tools:
```php
class MyTool extends CliFramework {
protected function configure(): void {
$this->setDescription('What this tool does');
$this->addArgument('--name', 'Description', 'default');
}
protected function run(): int {
$name = $this->getArgument('--name');
// ... business logic ...
return 0;
}
}
$app = new MyTool();
exit($app->execute());
```
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`
### Platform Adapters
Git operations are abstracted via `GitPlatformAdapter` interface:
- `MokoGiteaAdapter` — for git.mokoconsulting.tech (primary)
- `GitHubAdapter` — for github.com mirrors
### Plugin System
Platform-specific logic lives in `lib/Enterprise/Plugins/`. Each plugin implements `ProjectPluginInterface` with methods for health checks, validation, build commands, and config schemas.
## Code Quality
| Tool | Level | Config |
|------|-------|--------|
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
| PHPStan | Level 2 | `phpstan.neon` |
PHPStan runs with `--memory-limit=512M` due to large codebase. CI enforces PHPCS errors; PHPStan is advisory (`continue-on-error`).
## Rules
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
- **Attribution**: use `Authored-by: Moko Consulting` in commits
- **Branch strategy**: develop on `dev`, merge to `main` for release
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
- **After adding a CLI tool**: register it in `bin/moko` COMMAND_MAP
+4 -4
View File
@@ -2,16 +2,16 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoStandards.Root DEFGROUP: MokoPlatform.Root
INGROUP: MokoStandards INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /PLUGIN_SCRIPTS.md PATH: /PLUGIN_SCRIPTS.md
BRIEF: Plugin system CLI documentation BRIEF: Plugin system CLI documentation
--> -->
# Plugin System CLI Scripts # Plugin System CLI Scripts
Command-line scripts for validating, health checking, and managing projects using the MokoStandards plugin system. Command-line scripts for validating, health checking, and managing projects using the mokocli plugin system.
## Available Scripts ## Available Scripts
+8 -8
View File
@@ -2,22 +2,22 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoStandards.Root DEFGROUP: MokoPlatform.Root
INGROUP: MokoStandards INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /README.md PATH: /README.md
VERSION: 09.21.00 VERSION: 09.38.00
BRIEF: Project overview and documentation BRIEF: Project overview and documentation
--> -->
# MokoStandards Enterprise API # mokocli Enterprise API
![Version](https://img.shields.io/badge/version-09.01.00-blue) ![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green) ![Version](https://img.shields.io/badge/version-09.01.00-blue) ![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green)
PHP implementation of MokoStandards — enterprise standards, automation framework, workflow templates, and bulk sync tooling. PHP implementation of mokocli — enterprise standards, automation framework, workflow templates, and bulk sync tooling.
> **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API) > **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoCli-API)
> **Backup mirror**: [GitHub](https://github.com/MokoConsulting/MokoStandards-API) *(read-only mirror)* > **Backup mirror**: [GitHub](https://github.com/MokoConsulting/MokoCli-API) *(read-only mirror)*
## What Lives Here ## What Lives Here
+3 -3
View File
@@ -2,9 +2,9 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoStandards.Index DEFGROUP: MokoPlatform.Index
INGROUP: MokoStandards.Analysis INGROUP: MokoPlatform.Analysis
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /analysis/index.md PATH: /analysis/index.md
BRIEF: Analysis directory index BRIEF: Analysis directory index
--> -->
+7 -7
View File
@@ -9,9 +9,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards.Scripts * INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/bulk_joomla_template.php * PATH: /automation/bulk_joomla_template.php
* BRIEF: Bulk scaffold and sync Joomla template repositories * BRIEF: Bulk scaffold and sync Joomla template repositories
* *
@@ -28,7 +28,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{ use MokoCli\{
AuditLogger, AuditLogger,
CliFramework, CliFramework,
Config, Config,
@@ -42,7 +42,7 @@ use MokoEnterprise\{
* *
* Provides three operations for Joomla template projects: * Provides three operations for Joomla template projects:
* --scaffold: Create a new template repository with the full directory structure * --scaffold: Create a new template repository with the full directory structure
* --sync: Push MokoStandards files to existing template repositories * --sync: Push mokocli files to existing template repositories
* --list: List all repositories tagged as joomla-template * --list: List all repositories tagged as joomla-template
* *
* Works with both GitHub and Gitea via the PlatformAdapterFactory. * Works with both GitHub and Gitea via the PlatformAdapterFactory.
@@ -50,7 +50,7 @@ use MokoEnterprise\{
class BulkJoomlaTemplate extends CliFramework class BulkJoomlaTemplate extends CliFramework
{ {
public const DEFAULT_ORG = 'MokoConsulting'; public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '04.06.10'; public const VERSION = '09.23.00';
private GitPlatformAdapter $adapter; private GitPlatformAdapter $adapter;
private Config $config; private Config $config;
@@ -318,7 +318,7 @@ class BulkJoomlaTemplate extends CliFramework
$name, $name,
$path, $path,
$content, $content,
"chore: update {$path} from MokoStandards", "chore: update {$path} from mokocli",
$existingSha, $existingSha,
$branch $branch
); );
+44 -44
View File
@@ -9,9 +9,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards.Scripts * INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/bulk_sync.php * PATH: /automation/bulk_sync.php
* BRIEF: Enterprise-grade bulk repository synchronization * BRIEF: Enterprise-grade bulk repository synchronization
*/ */
@@ -21,7 +21,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{ use MokoCli\{
ApiClient, ApiClient,
AuditLogger, AuditLogger,
CheckpointManager, CheckpointManager,
@@ -42,7 +42,7 @@ use MokoEnterprise\{
/** /**
* Bulk Repository Synchronization Tool * Bulk Repository Synchronization Tool
* *
* Synchronizes MokoStandards files across multiple repositories using * Synchronizes mokocli files across multiple repositories using
* the Enterprise library for robust, audited operations. * the Enterprise library for robust, audited operations.
*/ */
class BulkSync extends CliFramework class BulkSync extends CliFramework
@@ -57,7 +57,7 @@ class BulkSync extends CliFramework
* Script version number * Script version number
* Public to allow script instantiation with class constants * Public to allow script instantiation with class constants
*/ */
public const VERSION = '04.06.00'; public const VERSION = '09.23.00';
public const VERSION_MINOR = '04.05'; public const VERSION_MINOR = '04.05';
private ApiClient $api; private ApiClient $api;
@@ -95,7 +95,7 @@ class BulkSync extends CliFramework
*/ */
protected function run(): int protected function run(): int
{ {
$this->log("🚀 MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO'); $this->log("🚀 mokocli Bulk Synchronization v" . self::VERSION, 'INFO');
// Initialize enterprise components // Initialize enterprise components
if (!$this->initializeComponents()) { if (!$this->initializeComponents()) {
@@ -180,7 +180,7 @@ class BulkSync extends CliFramework
$results['health'] = $this->runHealthChecksAll($org, $repositories); $results['health'] = $this->runHealthChecksAll($org, $repositories);
} }
// Create/update tracking issue in MokoStandards // Create/update tracking issue in mokocli
$this->createSyncIssue($org, $results); $this->createSyncIssue($org, $results);
// Create/update a failure issue when any repos failed // Create/update a failure issue when any repos failed
@@ -244,7 +244,7 @@ class BulkSync extends CliFramework
* Filter repositories based on include/exclude lists * Filter repositories based on include/exclude lists
*/ */
/** Repositories that are permanently excluded from bulk sync. */ /** Repositories that are permanently excluded from bulk sync. */
private const ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; private const ALWAYS_EXCLUDE = ['mokocli', '.github-private'];
private function filterRepositories(array $repositories, array $include, array $exclude): array private function filterRepositories(array $repositories, array $include, array $exclude): array
{ {
@@ -426,7 +426,7 @@ class BulkSync extends CliFramework
$this->log("", 'ERROR'); $this->log("", 'ERROR');
$this->log("Required Implementation:", 'ERROR'); $this->log("Required Implementation:", 'ERROR');
$this->log(" 1. Clone/fetch target repository", 'ERROR'); $this->log(" 1. Clone/fetch target repository", 'ERROR');
$this->log(" 2. Apply file updates based on MokoStandards configuration", 'ERROR'); $this->log(" 2. Apply file updates based on mokocli configuration", 'ERROR');
$this->log(" 3. Create pull request with changes", 'ERROR'); $this->log(" 3. Create pull request with changes", 'ERROR');
$this->log(" 4. Handle merge conflicts and validation", 'ERROR'); $this->log(" 4. Handle merge conflicts and validation", 'ERROR');
$this->log("", 'ERROR'); $this->log("", 'ERROR');
@@ -837,7 +837,7 @@ class BulkSync extends CliFramework
} }
/** /**
* Ensure all standard MokoStandards labels exist on a target repository. * Ensure all standard mokocli labels exist on a target repository.
* *
* Fetches existing labels first (GET) and only POSTs the ones that are * Fetches existing labels first (GET) and only POSTs the ones that are
* missing. This avoids the 422 "already exists" responses that would * missing. This avoids the 422 "already exists" responses that would
@@ -872,7 +872,7 @@ class BulkSync extends CliFramework
// Workflow / Process // Workflow / Process
['automation', '8B4513', 'Automated processes or scripts'], ['automation', '8B4513', 'Automated processes or scripts'],
['mokostandards', 'B60205', 'MokoStandards compliance'], ['mokocli', 'B60205', 'mokocli compliance'],
['needs-review', 'FBCA04', 'Awaiting code review'], ['needs-review', 'FBCA04', 'Awaiting code review'],
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'], ['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
['breaking-change', 'D73A4A', 'Breaking API or functionality change'], ['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
@@ -912,8 +912,8 @@ class BulkSync extends CliFramework
['health: poor', 'FF6B6B', 'Health score below 50'], ['health: poor', 'FF6B6B', 'Health score below 50'],
// Sync / Automation (used by bulk_sync, scan_drift, check_repo_health) // Sync / Automation (used by bulk_sync, scan_drift, check_repo_health)
['standards-update', 'B60205', 'MokoStandards sync update'], ['standards-update', 'B60205', 'mokocli sync update'],
['standards-drift', 'FBCA04', 'Repository drifted from MokoStandards'], ['standards-drift', 'FBCA04', 'Repository drifted from mokocli'],
['sync-report', '0075CA', 'Bulk sync run report'], ['sync-report', '0075CA', 'Bulk sync run report'],
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'], ['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
['push-failure', 'D73A4A', 'File push failure requiring attention'], ['push-failure', 'D73A4A', 'File push failure requiring attention'],
@@ -925,10 +925,10 @@ class BulkSync extends CliFramework
['type: version', '0E8A16', 'Version-related change'], ['type: version', '0E8A16', 'Version-related change'],
]; ];
// Quick check: if the repo already has the 'mokostandards' label, it was // Quick check: if the repo already has the 'mokocli' label, it was
// provisioned previously — skip the expensive full label provisioning. // provisioned previously — skip the expensive full label provisioning.
try { try {
$probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokostandards"); $probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokocli");
if (!empty($probe['name'])) { if (!empty($probe['name'])) {
return; // already provisioned return; // already provisioned
} }
@@ -1024,7 +1024,7 @@ class BulkSync extends CliFramework
*/ */
private function updateOpenBranches(string $org, string $repo): void private function updateOpenBranches(string $org, string $repo): void
{ {
$syncBranchPrefix = 'chore/sync-mokostandards-'; $syncBranchPrefix = 'chore/sync-mokocli-';
try { try {
$defaultBranch = 'main'; $defaultBranch = 'main';
@@ -1055,7 +1055,7 @@ class BulkSync extends CliFramework
$this->api->post("/repos/{$org}/{$repo}/merges", [ $this->api->post("/repos/{$org}/{$repo}/merges", [
'base' => $branch, 'base' => $branch,
'head' => $defaultBranch, 'head' => $defaultBranch,
'commit_message' => "chore: merge {$defaultBranch} into {$branch} (MokoStandards sync)", 'commit_message' => "chore: merge {$defaultBranch} into {$branch} (mokocli sync)",
]); ]);
$this->log(" 🔀 Merged {$defaultBranch}{$branch} (PR #{$prNum})", 'INFO'); $this->log(" 🔀 Merged {$defaultBranch}{$branch} (PR #{$prNum})", 'INFO');
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -1076,7 +1076,7 @@ class BulkSync extends CliFramework
/** /**
* Records which sync run touched the repo, the PR number, and the * Records which sync run touched the repo, the PR number, and the
* MokoStandards version that was applied — giving each repo a clear audit * mokocli version that was applied — giving each repo a clear audit
* trail of what was changed and why. * trail of what was changed and why.
*/ */
/** /**
@@ -1119,16 +1119,16 @@ class BulkSync extends CliFramework
$minor = self::VERSION_MINOR; $minor = self::VERSION_MINOR;
$force = isset($this->options['force']) ? ' *(--force)*' : ''; $force = isset($this->options['force']) ? ' *(--force)*' : '';
$prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber); $prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber);
$source = $this->adapter->getRepoWebUrl($org, 'MokoStandards'); $source = $this->adapter->getRepoWebUrl($org, 'mokocli');
$branchName = 'chore/sync-mokostandards-v' . $minor; $branchName = 'chore/sync-mokocli-v' . $minor;
$branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName); $branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName);
$title = "chore: MokoStandards v{$minor} sync tracking"; $title = "chore: mokocli v{$minor} sync tracking";
$body = <<<MD $body = <<<MD
## MokoStandards Sync Applied ## mokocli Sync Applied
A MokoStandards bulk sync run has updated files in this repository. A mokocli bulk sync run has updated files in this repository.
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
@@ -1144,13 +1144,13 @@ class BulkSync extends CliFramework
Protected files (README, CHANGELOG, GOVERNANCE, etc.) were not overwritten. Protected files (README, CHANGELOG, GOVERNANCE, etc.) were not overwritten.
--- ---
*Updated automatically by [MokoStandards]({$source}) `bulk_sync.php`* *Updated automatically by [mokocli]({$source}) `bulk_sync.php`*
MD; MD;
// Dedent heredoc // Dedent heredoc
$body = preg_replace('/^ /m', '', $body); $body = preg_replace('/^ /m', '', $body);
$labelNames = ['standards-update', 'mokostandards', 'type: chore', 'automation']; $labelNames = ['standards-update', 'mokocli', 'type: chore', 'automation'];
$labels = $this->resolveLabelIds($org, $repo, $labelNames); $labels = $this->resolveLabelIds($org, $repo, $labelNames);
try { try {
@@ -1213,7 +1213,7 @@ class BulkSync extends CliFramework
} }
/** /**
* Create a tracking issue in MokoStandards for this sync run. * Create a tracking issue in mokocli for this sync run.
*/ */
private function createSyncIssue(string $org, array $results): void private function createSyncIssue(string $org, array $results): void
{ {
@@ -1232,7 +1232,7 @@ class BulkSync extends CliFramework
$issues = $results['issues'] ?? []; $issues = $results['issues'] ?? [];
// Stable title — no timestamp so repeated runs update a single issue // Stable title — no timestamp so repeated runs update a single issue
$title = "sync: MokoStandards v" . self::VERSION_MINOR . " bulk sync report"; $title = "sync: mokocli v" . self::VERSION_MINOR . " bulk sync report";
$protection = $results['protection'] ?? []; $protection = $results['protection'] ?? [];
$hasProtect = !empty($protection); $hasProtect = !empty($protection);
@@ -1281,7 +1281,7 @@ class BulkSync extends CliFramework
: "|---|---|---|---|"; : "|---|---|---|---|";
$body = <<<MD $body = <<<MD
## MokoStandards Bulk Sync Report ## mokocli Bulk Sync Report
**Organisation:** `{$org}` **Organisation:** `{$org}`
**Triggered:** {$now}{$force} **Triggered:** {$now}{$force}
@@ -1301,7 +1301,7 @@ class BulkSync extends CliFramework
try { try {
// Search for existing issue by label — any state so we can reopen closed ones // Search for existing issue by label — any state so we can reopen closed ones
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ $existing = $this->api->get("/repos/{$org}/mokocli/issues", [
'labels' => 'sync-report', 'labels' => 'sync-report',
'state' => 'all', 'state' => 'all',
'per_page' => 1, 'per_page' => 1,
@@ -1309,8 +1309,8 @@ class BulkSync extends CliFramework
'direction' => 'desc', 'direction' => 'desc',
]); ]);
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation']; $labelNames = ['sync-report', 'mokocli', 'type: chore', 'automation'];
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames); $labels = $this->resolveLabelIds($org, 'mokocli', $labelNames);
$existing = array_values($existing); $existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) { if (!empty($existing) && isset($existing[0]['number'])) {
@@ -1319,22 +1319,22 @@ class BulkSync extends CliFramework
if (($existing[0]['state'] ?? 'open') === 'closed') { if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open'; $patch['state'] = 'open';
} }
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch); $this->api->patch("/repos/{$org}/mokocli/issues/{$issueNumber}", $patch);
try { try {
$this->api->post("/repos/{$org}/MokoStandards/issues/{$issueNumber}/labels", ['labels' => $labels]); $this->api->post("/repos/{$org}/mokocli/issues/{$issueNumber}/labels", ['labels' => $labels]);
} catch (\Exception $le) { } catch (\Exception $le) {
/* non-fatal */ /* non-fatal */
} }
$this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO'); $this->log("📋 Sync report issue updated: {$org}/mokocli#{$issueNumber}", 'INFO');
} else { } else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ $issue = $this->api->post("/repos/{$org}/mokocli/issues", [
'title' => $title, 'title' => $title,
'body' => $body, 'body' => $body,
'labels' => $labels, 'labels' => $labels,
'assignees' => ['jmiller'], 'assignees' => ['jmiller'],
]); ]);
$issueNumber = $issue['number'] ?? '?'; $issueNumber = $issue['number'] ?? '?';
$this->log("📋 Sync report issue created: {$org}/MokoStandards#{$issueNumber}", 'INFO'); $this->log("📋 Sync report issue created: {$org}/mokocli#{$issueNumber}", 'INFO');
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log("⚠️ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN'); $this->log("⚠️ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN');
@@ -1342,7 +1342,7 @@ class BulkSync extends CliFramework
} }
/** /**
* Create or update a failure issue in MokoStandards when repos fail to sync. * Create or update a failure issue in mokocli when repos fail to sync.
* Uses the 'sync-failure' label so it is distinct from the run-report issue. * Uses the 'sync-failure' label so it is distinct from the run-report issue.
* Reopens a closed issue rather than creating a duplicate. * Reopens a closed issue rather than creating a duplicate.
*/ */
@@ -1388,7 +1388,7 @@ class BulkSync extends CliFramework
$body = preg_replace('/^ /m', '', $body); $body = preg_replace('/^ /m', '', $body);
try { try {
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ $existing = $this->api->get("/repos/{$org}/mokocli/issues", [
'labels' => 'sync-failure', 'labels' => 'sync-failure',
'state' => 'all', 'state' => 'all',
'per_page' => 1, 'per_page' => 1,
@@ -1403,17 +1403,17 @@ class BulkSync extends CliFramework
if (($existing[0]['state'] ?? 'open') === 'closed') { if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open'; $patch['state'] = 'open';
} }
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch); $this->api->patch("/repos/{$org}/mokocli/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN'); $this->log("🚨 Failure issue #{$num} updated: {$org}/mokocli#{$num}", 'WARN');
} else { } else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ $issue = $this->api->post("/repos/{$org}/mokocli/issues", [
'title' => $title, 'title' => $title,
'body' => $body, 'body' => $body,
'labels' => $this->resolveLabelIds($org, 'MokoStandards', ['sync-failure']), 'labels' => $this->resolveLabelIds($org, 'mokocli', ['sync-failure']),
'assignees' => ['jmiller'], 'assignees' => ['jmiller'],
]); ]);
$num = $issue['number'] ?? '?'; $num = $issue['number'] ?? '?';
$this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN'); $this->log("🚨 Failure issue created: {$org}/mokocli#{$num}", 'WARN');
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN'); $this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
+123 -123
View File
@@ -1,123 +1,123 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# BRIEF: Trigger a workflow across all client-waas repos in a Gitea org # BRIEF: Trigger a workflow across all client-waas repos in a Gitea org
set -euo pipefail set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Usage # Usage
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
usage() { usage() {
cat <<EOF cat <<EOF
Usage: $(basename "$0") GITEA_URL TOKEN ORG WORKFLOW [REF] [INPUTS] Usage: $(basename "$0") GITEA_URL TOKEN ORG WORKFLOW [REF] [INPUTS]
Arguments: Arguments:
GITEA_URL Base URL of the Gitea instance (e.g. https://git.mokoconsulting.tech) GITEA_URL Base URL of the Gitea instance (e.g. https://git.mokoconsulting.tech)
TOKEN Gitea API token with repo/action permissions TOKEN Gitea API token with repo/action permissions
ORG Organisation or user that owns the repos ORG Organisation or user that owns the repos
WORKFLOW Workflow filename to trigger (e.g. dependency-audit.yml) WORKFLOW Workflow filename to trigger (e.g. dependency-audit.yml)
REF Branch ref to run against (default: main) REF Branch ref to run against (default: main)
INPUTS Optional JSON object of workflow inputs (e.g. '{"dry_run":"true"}') INPUTS Optional JSON object of workflow inputs (e.g. '{"dry_run":"true"}')
Example: Example:
$(basename "$0") https://git.mokoconsulting.tech abc123 MokoConsulting dependency-audit.yml main '{"notify":"true"}' $(basename "$0") https://git.mokoconsulting.tech abc123 MokoConsulting dependency-audit.yml main '{"notify":"true"}'
EOF EOF
exit 1 exit 1
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Argument parsing # Argument parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [ $# -lt 4 ]; then if [ $# -lt 4 ]; then
usage usage
fi fi
GITEA_URL="${1%/}" GITEA_URL="${1%/}"
TOKEN="$2" TOKEN="$2"
ORG="$3" ORG="$3"
WORKFLOW="$4" WORKFLOW="$4"
REF="${5:-main}" REF="${5:-main}"
INPUTS="${6:-{\}}" INPUTS="${6:-{\}}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fetch all repos in the org, paginated # Fetch all repos in the org, paginated
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "Fetching repos for org '${ORG}' on ${GITEA_URL} ..." echo "Fetching repos for org '${ORG}' on ${GITEA_URL} ..."
PAGE=1 PAGE=1
LIMIT=50 LIMIT=50
ALL_REPOS="" ALL_REPOS=""
while true; do while true; do
RESPONSE=$(curl -s \ RESPONSE=$(curl -s \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Accept: application/json" \ -H "Accept: application/json" \
"${GITEA_URL}/api/v1/orgs/${ORG}/repos?page=${PAGE}&limit=${LIMIT}") "${GITEA_URL}/api/v1/orgs/${ORG}/repos?page=${PAGE}&limit=${LIMIT}")
# Break if empty array # Break if empty array
COUNT=$(echo "$RESPONSE" | jq -r 'length') COUNT=$(echo "$RESPONSE" | jq -r 'length')
if [ "$COUNT" -eq 0 ]; then if [ "$COUNT" -eq 0 ]; then
break break
fi fi
NAMES=$(echo "$RESPONSE" | jq -r '.[].name') NAMES=$(echo "$RESPONSE" | jq -r '.[].name')
ALL_REPOS="${ALL_REPOS}${NAMES}"$'\n' ALL_REPOS="${ALL_REPOS}${NAMES}"$'\n'
if [ "$COUNT" -lt "$LIMIT" ]; then if [ "$COUNT" -lt "$LIMIT" ]; then
break break
fi fi
PAGE=$((PAGE + 1)) PAGE=$((PAGE + 1))
done done
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Filter for client-waas repos # Filter for client-waas repos
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CLIENT_REPOS=$(echo "$ALL_REPOS" | grep 'client-waas' | sort || true) CLIENT_REPOS=$(echo "$ALL_REPOS" | grep 'client-waas' | sort || true)
if [ -z "$CLIENT_REPOS" ]; then if [ -z "$CLIENT_REPOS" ]; then
echo "No client-waas repos found in org '${ORG}'." echo "No client-waas repos found in org '${ORG}'."
exit 0 exit 0
fi fi
TOTAL=$(echo "$CLIENT_REPOS" | wc -l | tr -d ' ') TOTAL=$(echo "$CLIENT_REPOS" | wc -l | tr -d ' ')
echo "Found ${TOTAL} client-waas repo(s). Triggering workflow '${WORKFLOW}' (ref: ${REF}) ..." echo "Found ${TOTAL} client-waas repo(s). Triggering workflow '${WORKFLOW}' (ref: ${REF}) ..."
echo "" echo ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Trigger workflow for each repo # Trigger workflow for each repo
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SUCCESS=0 SUCCESS=0
FAIL=0 FAIL=0
while IFS= read -r REPO; do while IFS= read -r REPO; do
[ -z "$REPO" ] && continue [ -z "$REPO" ] && continue
PAYLOAD=$(jq -n --arg ref "$REF" --argjson inputs "$INPUTS" '{ref: $ref, inputs: $inputs}') PAYLOAD=$(jq -n --arg ref "$REF" --argjson inputs "$INPUTS" '{ref: $ref, inputs: $inputs}')
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \ HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST \ -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$PAYLOAD" \ -d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${ORG}/${REPO}/actions/workflows/${WORKFLOW}/dispatches") "${GITEA_URL}/api/v1/repos/${ORG}/${REPO}/actions/workflows/${WORKFLOW}/dispatches")
if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 201 ]; then if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 201 ]; then
echo " [OK] ${ORG}/${REPO} (HTTP ${HTTP_CODE})" echo " [OK] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
SUCCESS=$((SUCCESS + 1)) SUCCESS=$((SUCCESS + 1))
else else
echo " [FAIL] ${ORG}/${REPO} (HTTP ${HTTP_CODE})" echo " [FAIL] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
done <<< "$CLIENT_REPOS" done <<< "$CLIENT_REPOS"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Summary # Summary
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "" echo ""
echo "Done. Success: ${SUCCESS} | Failed: ${FAIL} | Total: ${TOTAL}" echo "Done. Success: ${SUCCESS} | Failed: ${FAIL} | Total: ${TOTAL}"
if [ "$FAIL" -gt 0 ]; then if [ "$FAIL" -gt 0 ]; then
exit 1 exit 1
fi fi
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/enrich_manifest_xml.php
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
*
* Note: This script uses proc_open for shell commands. All arguments are escaped
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
class EnrichManifestXmlCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
echo "=== mokocli XML Manifest Enrichment ===\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " {$name} ... SKIP (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
$stats['skipped']++;
continue;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
}
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokocli')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = $this->inspectRepo($workDir, $platform);
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language']
?? $repo['language']
?? ManifestParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? ManifestParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($this->dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
$this->rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
[$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich manifest.xml with build/deploy/scripts\n\nAuto-detected: {$details}");
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
[$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
$this->rmTree($workDir);
}
@rmdir($tmpBase);
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
return 0;
}
private function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
// Detect entry point
if (is_dir("{$workDir}/src")) {
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
$c = file_get_contents($xf);
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
$build['entry_point'] = 'src/' . basename($xf);
break;
}
}
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
}
// composer.json
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$phpReq = $composer['require']['php'] ?? null;
if ($phpReq) {
$build['runtime'] = "php:{$phpReq}";
}
$deps = [];
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
if (isset($composer['require'][$pd])) {
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
}
}
if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
$deps[] = [
'name' => 'mokoconsulting-tech/enterprise',
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
'type' => 'composer',
];
}
if (!empty($deps)) {
$build['dependencies'] = $deps;
}
}
// Artifact from Makefile
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
}
}
if (!empty($build)) {
$enrichment['build'] = $build;
}
// Deploy targets from workflows
$targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
if (!file_exists($wf)) {
continue;
}
$wc = file_get_contents($wf);
$t = ['name' => str_replace('deploy-', '', $dn)];
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
$t['method'] = 'sftp';
} elseif (str_contains($wc, 'rsync')) {
$t['method'] = 'rsync';
}
if (str_contains($wc, 'src/')) {
$t['src_dir'] = 'src/';
}
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
$t['branch'] = $m[1];
}
$targets[] = $t;
}
}
if (!empty($targets)) {
$enrichment['deploy'] = $targets;
}
// Scripts from Makefile + composer
$scripts = [];
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
$known = [
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
'clean' => 'build', 'package' => 'build',
'validate' => 'validate', 'release' => 'release',
];
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
foreach ($matches[1] as $tgt) {
$tl = strtolower($tgt);
if (isset($known[$tl])) {
$scripts[] = [
'name' => $tl, 'phase' => $known[$tl],
'command' => "make {$tgt}",
'desc' => ucfirst($tl) . ' via make',
'runner' => 'make',
];
}
}
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
$sl = strtolower($sn);
foreach ($km as $match => $phase) {
if (str_contains($sl, $match)) {
$exists = false;
foreach ($scripts as $s) {
if ($s['name'] === $sl) {
$exists = true;
break;
}
}
if (!$exists) {
$scripts[] = [
'name' => $sn, 'phase' => $phase,
'command' => "composer run {$sn}",
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
'runner' => 'composer',
];
}
break;
}
}
}
}
if (!empty($scripts)) {
$enrichment['scripts'] = $scripts;
}
return $enrichment;
}
private function enrichManifestXml(string $xml, array $enrichment): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) {
return $xml;
}
$ns = ManifestParser::NAMESPACE_URI;
$root = $dom->documentElement;
foreach (['build', 'deploy', 'scripts'] as $tag) {
$toRemove = [];
$existing = $root->getElementsByTagNameNS($ns, $tag);
for ($i = 0; $i < $existing->length; $i++) {
$toRemove[] = $existing->item($i);
}
foreach ($toRemove as $node) {
$root->removeChild($node);
}
}
if (!empty($enrichment['build'])) {
$buildEl = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
}
}
if (isset($b['package_type'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
}
if (isset($b['artifact'])) {
$art = $dom->createElementNS($ns, 'artifact');
foreach (['format','path','filename'] as $af) {
if (isset($b['artifact'][$af])) {
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
}
}
$buildEl->appendChild($art);
}
if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies');
foreach ($b['dependencies'] as $d) {
$req = $dom->createElementNS($ns, 'requires', '');
$req->setAttribute('name', $d['name']);
if (isset($d['version'])) {
$req->setAttribute('version', $d['version']);
}
if (isset($d['type'])) {
$req->setAttribute('type', $d['type']);
}
$deps->appendChild($req);
}
$buildEl->appendChild($deps);
}
$root->appendChild($buildEl);
}
if (!empty($enrichment['deploy'])) {
$deploy = $dom->createElementNS($ns, 'deploy');
foreach ($enrichment['deploy'] as $t) {
$target = $dom->createElementNS($ns, 'target');
$target->setAttribute('name', $t['name']);
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
if (isset($t['method'])) {
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
}
if (isset($t['branch'])) {
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
}
if (isset($t['src_dir'])) {
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
}
$deploy->appendChild($target);
}
$root->appendChild($deploy);
}
if (!empty($enrichment['scripts'])) {
$scriptsEl = $dom->createElementNS($ns, 'scripts');
foreach ($enrichment['scripts'] as $s) {
$script = $dom->createElementNS($ns, 'script');
$script->setAttribute('name', $s['name']);
if (isset($s['phase'])) {
$script->setAttribute('phase', $s['phase']);
}
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
if (isset($s['desc'])) {
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
}
if (isset($s['runner'])) {
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
}
$scriptsEl->appendChild($script);
}
$root->appendChild($scriptsEl);
}
return $dom->saveXML();
}
/** @return array{int, string} */
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
if (!is_resource($proc)) {
return [1, "proc_open failed"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/** @return array{int, string} */
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new EnrichManifestXmlCli();
exit($app->execute());
+429 -419
View File
@@ -6,20 +6,12 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/enrich_mokostandards_xml.php * PATH: /automation/enrich_mokostandards_xml.php
* BRIEF: Enrich XML manifests with repo-specific build and deploy details * BRIEF: Enrich XML manifests with repo-specific build and deploy details
* *
* Enrich XML .mokostandards manifests with repo-specific build, deploy, and script details.
*
* Runs AFTER push_mokostandards_xml.php. Clones each repo, inspects its contents,
* and updates the manifest with discovered build/deploy/scripts config.
*
* Usage:
* php automation/enrich_mokostandards_xml.php [--dry-run] [--repo NAME] [--skip NAME,NAME]
*
* Note: This script uses proc_open for shell commands. All arguments are escaped * Note: This script uses proc_open for shell commands. All arguments are escaped
* via escapeshellarg(). No user-supplied input reaches the shell unescaped. * via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/ */
@@ -27,448 +19,466 @@
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\MokoStandardsParser; use MokoCli\CliFramework;
use MokoCli\ManifestParser;
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); class EnrichMokostandardsXmlCli extends CliFramework
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$dryRun = in_array('--dry-run', $argv, true);
$repoFilter = null;
$skipRepos = [];
foreach ($argv as $i => $arg) {
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoFilter = $argv[$i + 1];
}
if ($arg === '--skip' && isset($argv[$i + 1])) {
$skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
}
}
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
function safeExec(string $command, string $cwd = '.'): array
{ {
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd); protected function configure(): void
if (!is_resource($proc)) { {
return [1, "proc_open failed"]; $this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
} $this->addArgument('--repo', 'Filter to a single repo name', '');
$stdout = stream_get_contents($pipes[1]); $this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return safeExec($cmd, $workDir);
}
function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
// Detect entry point
if (is_dir("{$workDir}/src")) {
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
$c = file_get_contents($xf);
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
$build['entry_point'] = 'src/' . basename($xf);
break;
}
}
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
} }
// composer.json protected function run(): int
if (file_exists("{$workDir}/composer.json")) { {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: []; $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$phpReq = $composer['require']['php'] ?? null; $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
if ($phpReq) { $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$build['runtime'] = "php:{$phpReq}";
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
echo "=== mokocli XML Manifest Enrichment ===\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN required');
return 1;
} }
$deps = []; $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) { echo "Found " . count($repos) . " repositories\n\n";
if (isset($composer['require'][$pd])) {
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
}
}
if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
$deps[] = [
'name' => 'mokoconsulting-tech/enterprise',
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
'type' => 'composer',
];
}
if (!empty($deps)) {
$build['dependencies'] = $deps;
}
}
// Artifact from Makefile $stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
}
}
if (!empty($build)) { foreach ($repos as $repo) {
$enrichment['build'] = $build; $name = $repo['name'];
} if ($repoFilter && $name !== $repoFilter) {
// Deploy targets from workflows
$targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
if (!file_exists($wf)) {
continue; continue;
} }
$wc = file_get_contents($wf); if (in_array($name, $skipRepos, true)) {
$t = ['name' => str_replace('deploy-', '', $dn)]; echo " {$name} ... SKIP (excluded)\n";
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) { $stats['skipped']++;
$t['method'] = 'sftp'; continue;
} elseif (str_contains($wc, 'rsync')) {
$t['method'] = 'rsync';
} }
if (str_contains($wc, 'src/')) { if ($repo['archived'] ?? false) {
$t['src_dir'] = 'src/'; $stats['skipped']++;
continue;
} }
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
$t['branch'] = $m[1]; $defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
} }
$targets[] = $t;
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokocli')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = $this->inspectRepo($workDir, $platform);
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language']
?? $repo['language']
?? ManifestParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? ManifestParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($this->dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
$this->rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
$commitMsg = "chore: enrich .mokostandards"
. " with build/deploy/scripts\n\n"
. "Auto-detected: {$details}";
[$cr, $co] = $this->gitCmd(
$workDir,
'commit',
'-m',
$commitMsg
);
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
[$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
$this->rmTree($workDir);
} }
}
if (!empty($targets)) { @rmdir($tmpBase);
$enrichment['deploy'] = $targets; echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
return 0;
} }
// Scripts from Makefile + composer private function inspectRepo(string $workDir, string $platform): array
$scripts = []; {
if (file_exists("{$workDir}/Makefile")) { $enrichment = [];
$mk = file_get_contents("{$workDir}/Makefile"); $build = [];
$known = [
'build' => 'build', 'test' => 'test', 'lint' => 'lint', if (is_dir("{$workDir}/src")) {
'clean' => 'build', 'package' => 'build', foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
'validate' => 'validate', 'release' => 'release', $c = file_get_contents($xf);
]; if (str_contains($c, '<extension') || str_contains($c, '<install')) {
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) { $build['entry_point'] = 'src/' . basename($xf);
foreach ($matches[1] as $tgt) {
$tl = strtolower($tgt);
if (isset($known[$tl])) {
$scripts[] = [
'name' => $tl, 'phase' => $known[$tl],
'command' => "make {$tgt}",
'desc' => ucfirst($tl) . ' via make',
'runner' => 'make',
];
}
}
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
$sl = strtolower($sn);
foreach ($km as $match => $phase) {
if (str_contains($sl, $match)) {
$exists = false;
foreach ($scripts as $s) {
if ($s['name'] === $sl) {
$exists = true;
break;
}
}
if (!$exists) {
$scripts[] = [
'name' => $sn, 'phase' => $phase,
'command' => "composer run {$sn}",
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
'runner' => 'composer',
];
}
break; break;
} }
} }
} foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
} $build['entry_point'] = str_replace("{$workDir}/", '', $mf);
if (!empty($scripts)) { break;
$enrichment['scripts'] = $scripts;
}
return $enrichment;
}
function enrichManifestXml(string $xml, array $enrichment): string
{
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) {
return $xml;
}
$ns = MokoStandardsParser::NAMESPACE_URI;
$root = $dom->documentElement;
foreach (['build', 'deploy', 'scripts'] as $tag) {
$toRemove = [];
$existing = $root->getElementsByTagNameNS($ns, $tag);
for ($i = 0; $i < $existing->length; $i++) {
$toRemove[] = $existing->item($i);
}
foreach ($toRemove as $node) {
$root->removeChild($node);
}
}
if (!empty($enrichment['build'])) {
$build = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$build->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
} }
} }
if (isset($b['package_type'])) {
$build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1))); if (file_exists("{$workDir}/composer.json")) {
} $composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
if (isset($b['entry_point'])) { $phpReq = $composer['require']['php'] ?? null;
$build->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1))); if ($phpReq) {
} $build['runtime'] = "php:{$phpReq}";
if (isset($b['artifact'])) { }
$art = $dom->createElementNS($ns, 'artifact');
foreach (['format','path','filename'] as $af) { $deps = [];
if (isset($b['artifact'][$af])) { foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1))); if (isset($composer['require'][$pd])) {
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
} }
} }
$build->appendChild($art); if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
$deps[] = [
'name' => 'mokoconsulting-tech/enterprise',
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
'type' => 'composer',
];
}
if (!empty($deps)) {
$build['dependencies'] = $deps;
}
} }
if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies'); if (file_exists("{$workDir}/Makefile")) {
foreach ($b['dependencies'] as $d) { $mk = file_get_contents("{$workDir}/Makefile");
$req = $dom->createElementNS($ns, 'requires', ''); if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
$req->setAttribute('name', $d['name']); $build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
if (isset($d['version'])) { }
$req->setAttribute('version', $d['version']); }
if (!empty($build)) {
$enrichment['build'] = $build;
}
$targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
if (!file_exists($wf)) {
continue;
} }
if (isset($d['type'])) { $wc = file_get_contents($wf);
$req->setAttribute('type', $d['type']); $t = ['name' => str_replace('deploy-', '', $dn)];
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
$t['method'] = 'sftp';
} elseif (str_contains($wc, 'rsync')) {
$t['method'] = 'rsync';
} }
$deps->appendChild($req); if (str_contains($wc, 'src/')) {
$t['src_dir'] = 'src/';
}
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
$t['branch'] = $m[1];
}
$targets[] = $t;
} }
$build->appendChild($deps);
} }
$root->appendChild($build); if (!empty($targets)) {
} $enrichment['deploy'] = $targets;
if (!empty($enrichment['deploy'])) {
$deploy = $dom->createElementNS($ns, 'deploy');
foreach ($enrichment['deploy'] as $t) {
$target = $dom->createElementNS($ns, 'target');
$target->setAttribute('name', $t['name']);
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
if (isset($t['method'])) {
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
}
if (isset($t['branch'])) {
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
}
if (isset($t['src_dir'])) {
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
}
$deploy->appendChild($target);
} }
$root->appendChild($deploy);
}
if (!empty($enrichment['scripts'])) { $scripts = [];
$scriptsEl = $dom->createElementNS($ns, 'scripts'); if (file_exists("{$workDir}/Makefile")) {
foreach ($enrichment['scripts'] as $s) { $mk = file_get_contents("{$workDir}/Makefile");
$script = $dom->createElementNS($ns, 'script'); $known = [
$script->setAttribute('name', $s['name']); 'build' => 'build', 'test' => 'test', 'lint' => 'lint',
if (isset($s['phase'])) { 'clean' => 'build', 'package' => 'build',
$script->setAttribute('phase', $s['phase']); 'validate' => 'validate', 'release' => 'release',
];
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
foreach ($matches[1] as $tgt) {
$tl = strtolower($tgt);
if (isset($known[$tl])) {
$scripts[] = [
'name' => $tl, 'phase' => $known[$tl],
'command' => "make {$tgt}",
'desc' => ucfirst($tl) . ' via make',
'runner' => 'make',
];
}
}
} }
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
if (isset($s['desc'])) {
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
}
if (isset($s['runner'])) {
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
}
$scriptsEl->appendChild($script);
} }
$root->appendChild($scriptsEl); if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
$sl = strtolower($sn);
foreach ($km as $match => $phase) {
if (str_contains($sl, $match)) {
$exists = false;
foreach ($scripts as $s) {
if ($s['name'] === $sl) {
$exists = true;
break;
}
}
if (!$exists) {
$scripts[] = [
'name' => $sn, 'phase' => $phase,
'command' => "composer run {$sn}",
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
'runner' => 'composer',
];
}
break;
}
}
}
}
if (!empty($scripts)) {
$enrichment['scripts'] = $scripts;
}
return $enrichment;
} }
return $dom->saveXML(); private function enrichManifestXml(string $xml, array $enrichment): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) {
return $xml;
}
$ns = ManifestParser::NAMESPACE_URI;
$root = $dom->documentElement;
foreach (['build', 'deploy', 'scripts'] as $tag) {
$toRemove = [];
$existing = $root->getElementsByTagNameNS($ns, $tag);
for ($i = 0; $i < $existing->length; $i++) {
$toRemove[] = $existing->item($i);
}
foreach ($toRemove as $node) {
$root->removeChild($node);
}
}
if (!empty($enrichment['build'])) {
$buildEl = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
}
}
if (isset($b['package_type'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
}
if (isset($b['artifact'])) {
$art = $dom->createElementNS($ns, 'artifact');
foreach (['format','path','filename'] as $af) {
if (isset($b['artifact'][$af])) {
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
}
}
$buildEl->appendChild($art);
}
if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies');
foreach ($b['dependencies'] as $d) {
$req = $dom->createElementNS($ns, 'requires', '');
$req->setAttribute('name', $d['name']);
if (isset($d['version'])) {
$req->setAttribute('version', $d['version']);
}
if (isset($d['type'])) {
$req->setAttribute('type', $d['type']);
}
$deps->appendChild($req);
}
$buildEl->appendChild($deps);
}
$root->appendChild($buildEl);
}
if (!empty($enrichment['deploy'])) {
$deploy = $dom->createElementNS($ns, 'deploy');
foreach ($enrichment['deploy'] as $t) {
$target = $dom->createElementNS($ns, 'target');
$target->setAttribute('name', $t['name']);
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
if (isset($t['method'])) {
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
}
if (isset($t['branch'])) {
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
}
if (isset($t['src_dir'])) {
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
}
$deploy->appendChild($target);
}
$root->appendChild($deploy);
}
if (!empty($enrichment['scripts'])) {
$scriptsEl = $dom->createElementNS($ns, 'scripts');
foreach ($enrichment['scripts'] as $s) {
$script = $dom->createElementNS($ns, 'script');
$script->setAttribute('name', $s['name']);
if (isset($s['phase'])) {
$script->setAttribute('phase', $s['phase']);
}
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
if (isset($s['desc'])) {
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
}
if (isset($s['runner'])) {
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
}
$scriptsEl->appendChild($script);
}
$root->appendChild($scriptsEl);
}
return $dom->saveXML();
}
/** @return array{int, string} */
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
if (!is_resource($proc)) {
return [1, "proc_open failed"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/** @return array{int, string} */
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
} }
// ── Main ───────────────────────────────────────────────────────────────── $app = new EnrichMokostandardsXmlCli();
echo "=== MokoStandards XML Manifest Enrichment ===\n"; exit($app->execute());
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
fprintf(STDERR, "ERROR: GA_TOKEN required\n");
exit(1);
}
$repos = fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " {$name} ... SKIP (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
$stats['skipped']++;
continue;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
}
$manifestPath = "{$workDir}/.mokogitea/.mokostandards";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokostandards')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = inspectRepo($workDir, $platform);
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
$enrichedXml = enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
[$cr, $co] = gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}");
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
rmTree($workDir);
continue;
}
[$pr] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
rmTree($workDir);
}
@rmdir($tmpBase);
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
+3 -3
View File
@@ -2,9 +2,9 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION FILE INFORMATION
DEFGROUP: MokoStandards.Index DEFGROUP: MokoPlatform.Index
INGROUP: MokoStandards.Automation INGROUP: MokoPlatform.Automation
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /automation/index.md PATH: /automation/index.md
BRIEF: Automation directory index BRIEF: Automation directory index
--> -->
+11 -11
View File
@@ -8,16 +8,16 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/migrate_to_gitea.php * PATH: /automation/migrate_to_gitea.php
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance * BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
* *
* USAGE * USAGE
* php automation/migrate_to_gitea.php --dry-run * php automation/migrate_to_gitea.php --dry-run
* php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods * php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
* php automation/migrate_to_gitea.php --exclude MokoStandards --skip-archived * php automation/migrate_to_gitea.php --exclude mokocli --skip-archived
* php automation/migrate_to_gitea.php --resume * php automation/migrate_to_gitea.php --resume
*/ */
@@ -25,12 +25,12 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\CheckpointManager; use MokoCli\CheckpointManager;
use MokoEnterprise\CliFramework; use MokoCli\CliFramework;
use MokoEnterprise\Config; use MokoCli\Config;
use MokoEnterprise\PlatformAdapterFactory; use MokoCli\PlatformAdapterFactory;
use MokoEnterprise\GitHubAdapter; use MokoCli\GitHubAdapter;
use MokoEnterprise\MokoGiteaAdapter; use MokoCli\MokoGiteaAdapter;
/** /**
* Gitea Migration Script * Gitea Migration Script
@@ -278,7 +278,7 @@ class MigrateToGitea extends CliFramework
try { try {
$this->gitea->createIssue( $this->gitea->createIssue(
$giteaOrg, $giteaOrg,
'MokoStandards', 'mokocli',
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated', 'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
$report, $report,
['labels' => ['automation', 'type: chore']] ['labels' => ['automation', 'type: chore']]
+26 -25
View File
@@ -9,9 +9,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards.Scripts * INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_files.php * PATH: /automation/push_files.php
* BRIEF: Push one or more specific files to one or more remote repositories * BRIEF: Push one or more specific files to one or more remote repositories
*/ */
@@ -21,7 +21,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{ use MokoCli\{
ApiClient, ApiClient,
AuditLogger, AuditLogger,
CliFramework, CliFramework,
@@ -35,7 +35,7 @@ use MokoEnterprise\{
/** /**
* Targeted File Push Tool * Targeted File Push Tool
* *
* Pushes one or more specific files from MokoStandards templates to one or * Pushes one or more specific files from mokocli templates to one or
* more remote repositories — without running a full sync. * more remote repositories — without running a full sync.
* *
* Files are specified by their destination path as they appear in the target * Files are specified by their destination path as they appear in the target
@@ -53,7 +53,7 @@ use MokoEnterprise\{
class PushFiles extends CliFramework class PushFiles extends CliFramework
{ {
public const DEFAULT_ORG = 'MokoConsulting'; public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '04.06.00'; public const VERSION = '09.23.00';
private ApiClient $api; private ApiClient $api;
private GitPlatformAdapter $adapter; private GitPlatformAdapter $adapter;
@@ -81,7 +81,7 @@ class PushFiles extends CliFramework
*/ */
protected function run(): int protected function run(): int
{ {
$this->log('📦 MokoStandards File Push v' . self::VERSION, 'INFO'); $this->log('📦 mokocli File Push v' . self::VERSION, 'INFO');
if (!$this->initializeComponents()) { if (!$this->initializeComponents()) {
return 1; return 1;
@@ -230,7 +230,8 @@ class PushFiles extends CliFramework
{ {
// Read platform from repo's .mokogitea/manifest.xml via API // Read platform from repo's .mokogitea/manifest.xml via API
try { try {
$manifestData = $this->adapter->getFileContent($org, $repo, '.mokogitea/manifest.xml', 'main'); $fileInfo = $this->adapter->getFileContents($org, $repo, '.mokogitea/manifest.xml', 'main');
$manifestData = isset($fileInfo['content']) ? base64_decode($fileInfo['content']) : '';
if (!empty($manifestData)) { if (!empty($manifestData)) {
$xml = @simplexml_load_string($manifestData); $xml = @simplexml_load_string($manifestData);
if ($xml !== false) { if ($xml !== false) {
@@ -336,7 +337,7 @@ class PushFiles extends CliFramework
$prNumber = null; $prNumber = null;
if (!$direct) { if (!$direct) {
$prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards"; $prTitle = "chore: push " . count($entries) . " file(s) from mokocli";
$prBody = $this->buildPRBody($entries); $prBody = $this->buildPRBody($entries);
$pr = $this->adapter->createPullRequest( $pr = $this->adapter->createPullRequest(
$org, $org,
@@ -413,7 +414,7 @@ class PushFiles extends CliFramework
$message = !empty($customMessage) $message = !empty($customMessage)
? $customMessage ? $customMessage
: "chore: update {$destPath} from MokoStandards"; : "chore: update {$destPath} from mokocli";
// Fetch existing file SHA (needed for updates) // Fetch existing file SHA (needed for updates)
$existingSha = null; $existingSha = null;
@@ -456,9 +457,9 @@ class PushFiles extends CliFramework
): void { ): void {
$now = gmdate('Y-m-d H:i:s') . ' UTC'; $now = gmdate('Y-m-d H:i:s') . ' UTC';
$version = self::VERSION; $version = self::VERSION;
$source = $this->adapter->getRepoWebUrl($org, 'MokoStandards'); $source = $this->adapter->getRepoWebUrl($org, 'mokocli');
$title = "chore: MokoStandards file push tracking"; $title = "chore: mokocli file push tracking";
$deliveryLine = $prNumber !== null $deliveryLine = $prNumber !== null
? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |" ? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |"
@@ -470,9 +471,9 @@ class PushFiles extends CliFramework
)); ));
$body = <<<MD $body = <<<MD
## MokoStandards File Push ## mokocli File Push
One or more files were pushed to this repository from MokoStandards. One or more files were pushed to this repository from mokocli.
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
@@ -486,12 +487,12 @@ class PushFiles extends CliFramework
{$fileRows} {$fileRows}
--- ---
*Generated automatically by [MokoStandards]({$source}) `push_files.php`* *Generated automatically by [mokocli]({$source}) `push_files.php`*
MD; MD;
$body = preg_replace('/^ /m', '', $body); $body = preg_replace('/^ /m', '', $body);
$labels = ['standards-update', 'mokostandards', 'type: chore', 'automation']; $labels = ['standards-update', 'mokocli', 'type: chore', 'automation'];
try { try {
$existing = $this->api->get("/repos/{$org}/{$repo}/issues", [ $existing = $this->api->get("/repos/{$org}/{$repo}/issues", [
@@ -549,7 +550,7 @@ class PushFiles extends CliFramework
} }
/** /**
* Create or update a failure issue in MokoStandards when repos fail to receive files. * Create or update a failure issue in mokocli when repos fail to receive files.
* Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate. * Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate.
*/ */
private function createFailureIssue(string $org, array $results): void private function createFailureIssue(string $org, array $results): void
@@ -597,7 +598,7 @@ class PushFiles extends CliFramework
$body = preg_replace('/^ /m', '', $body); $body = preg_replace('/^ /m', '', $body);
try { try {
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ $existing = $this->api->get("/repos/{$org}/mokocli/issues", [
'labels' => 'push-failure', 'labels' => 'push-failure',
'state' => 'all', 'state' => 'all',
'per_page' => 1, 'per_page' => 1,
@@ -612,17 +613,17 @@ class PushFiles extends CliFramework
if (($existing[0]['state'] ?? 'open') === 'closed') { if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open'; $patch['state'] = 'open';
} }
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch); $this->api->patch("/repos/{$org}/mokocli/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN'); $this->log("🚨 Failure issue #{$num} updated: {$org}/mokocli#{$num}", 'WARN');
} else { } else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ $issue = $this->api->post("/repos/{$org}/mokocli/issues", [
'title' => $title, 'title' => $title,
'body' => $body, 'body' => $body,
'labels' => ['push-failure'], 'labels' => ['push-failure'],
'assignees' => ['jmiller'], 'assignees' => ['jmiller'],
]); ]);
$num = $issue['number'] ?? '?'; $num = $issue['number'] ?? '?';
$this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN'); $this->log("🚨 Failure issue created: {$org}/mokocli#{$num}", 'WARN');
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN'); $this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
@@ -637,14 +638,14 @@ class PushFiles extends CliFramework
private function buildPRBody(array $entries): string private function buildPRBody(array $entries): string
{ {
$now = gmdate('Y-m-d H:i:s') . ' UTC'; $now = gmdate('Y-m-d H:i:s') . ' UTC';
$lines = ["## MokoStandards File Push\n", "**Pushed:** {$now}\n", '### Files\n']; $lines = ["## mokocli File Push\n", "**Pushed:** {$now}\n", '### Files\n'];
foreach ($entries as $entry) { foreach ($entries as $entry) {
$lines[] = "- `{$entry['destination']}`"; $lines[] = "- `{$entry['destination']}`";
} }
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'MokoStandards'); $sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'mokocli');
$lines[] = "\n---\n*Generated by [MokoStandards]({$sourceUrl}) `push_files.php`*"; $lines[] = "\n---\n*Generated by [mokocli]({$sourceUrl}) `push_files.php`*";
return implode("\n", $lines); return implode("\n", $lines);
} }
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_manifest_xml.php
* BRIEF: Push XML manifests to all governed repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
class PushManifestXmlCli extends CliFramework
{
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
protected function configure(): void
{
$this->setDescription('Push XML manifest.xml to all governed repositories');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$this->addArgument('--force', 'Force overwrite even if already XML', false);
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
echo "=== mokocli XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? ManifestParser::platformLanguage($platform),
'package_type' => ManifestParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokocli');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards', '.gitea/.mokostandards', '.mokogitea/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update manifest.xml';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
return 0;
}
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
return 'crm-platform';
}
if (in_array('dolibarr-platform', $topics)) {
return 'crm-platform';
}
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
}
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla-template';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'waas-component';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) {
return 'joomla-template';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
}
/**
* @return array{int, string}
*/
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd
);
if (!is_resource($proc)) {
return [1, "proc_open failed for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/**
* @return array{int, string}
*/
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
$this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new PushManifestXmlCli();
exit($app->execute());
+297 -305
View File
@@ -6,348 +6,340 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_mokostandards_xml.php * PATH: /automation/push_mokostandards_xml.php
* BRIEF: Push XML manifests to all governed repositories * BRIEF: Push XML manifests to all governed repositories
*
* Push XML .mokostandards manifest to all governed repositories.
*
* Uses git SSH to bypass the Gitea reverse-proxy WAF that blocks
* API requests to paths containing ".mokogitea".
*
* Usage:
* php automation/push_mokostandards_xml.php [--dry-run] [--repo NAME] [--force]
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\MokoStandardsParser; use MokoCli\CliFramework;
use MokoCli\ManifestParser;
// ── Configuration ──────────────────────────────────────────────────────── class PushMokostandardsXmlCli extends CliFramework
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$sshBase = 'ssh://gitea@git.mokoconsulting.tech:2222';
// ── CLI args ─────────────────────────────────────────────────────────────
$dryRun = in_array('--dry-run', $argv, true);
$force = in_array('--force', $argv, true);
$repoFilter = null;
$skipRepos = [];
foreach ($argv as $i => $arg) {
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoFilter = $argv[$i + 1];
}
if ($arg === '--skip' && isset($argv[$i + 1])) {
$skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
}
}
$parser = new MokoStandardsParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
// ── Platform detection heuristics (mirrors RepositorySynchronizer) ───────
$CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
function detectPlatform(array $repo): string
{ {
global $CRM_PLATFORM_REPOS; private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, $CRM_PLATFORM_REPOS, true)) { protected function configure(): void
return 'crm-platform'; {
} $this->setDescription('Push XML manifests to all governed repositories');
if (in_array('dolibarr-platform', $topics)) { $this->addArgument('--repo', 'Filter to a single repo name', '');
return 'crm-platform'; $this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
} $this->addArgument('--force', 'Force overwrite even if already XML', false);
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
} }
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) { protected function run(): int
return 'joomla-template'; {
} $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
return 'waas-component'; $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) { $force = $this->getArgument('--force');
return 'joomla-template'; $repoFilter = $this->getArgument('--repo') ?: null;
} $skipStr = $this->getArgument('--skip');
if (str_contains($description, 'joomla') || str_contains($description, 'component')) { $skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) { $parser = new ManifestParser();
return 'standards-repository'; $tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
}
return 'default-repository';
}
/** echo "=== mokocli XML Manifest Push ===\n";
* Safe shell execution — uses proc_open with explicit arguments to avoid injection. echo "Org: {$giteaOrg}\n";
* @return array{int, string} echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
*/ if ($repoFilter) {
function safeExec(string $command, string $cwd = '.'): array echo "Filter: {$repoFilter}\n";
{
$proc = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd
);
if (!is_resource($proc)) {
return [1, "proc_open failed for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
}
/** Recursively remove a directory (cross-platform). */
function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
// Clear read-only flag (git objects on Windows)
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
} }
} echo "\n";
@rmdir($dir);
}
/** if (empty($token)) {
* Run a git command safely in a given working directory. $this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
* @return array{int, string} return 1;
*/
function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return safeExec($cmd, $workDir);
}
// ── Fetch all repos via API ──────────────────────────────────────────────
function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
fprintf(STDERR, "API error (HTTP %d) fetching repos page %d\n", $code, $page);
break;
} }
$batch = json_decode($body, true); $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
if (empty($batch)) { echo "Found " . count($repos) . " repositories\n\n";
break;
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? ManifestParser::platformLanguage($platform),
'package_type' => ManifestParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokocli');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update .mokostandards to XML format';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
} }
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos; // Cleanup tmp base
} @rmdir($tmpBase);
// ── Main ───────────────────────────────────────────────────────────────── echo "\n=== Summary ===\n";
echo "=== MokoStandards XML Manifest Push ===\n"; echo "Created: {$stats['created']}\n";
echo "Org: {$giteaOrg}\n"; echo "Updated: {$stats['updated']}\n";
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n"; echo "Skipped: {$stats['skipped']}\n";
if ($repoFilter) { echo "Failed: {$stats['failed']}\n";
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) { return 0;
fprintf(STDERR, "ERROR: GA_TOKEN or GH_TOKEN environment variable required\n");
exit(1);
}
$repos = fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
} }
$platform = detectPlatform($repo); private function detectPlatform(array $repo): string
$defaultBranch = $repo['default_branch'] ?? 'main'; {
// Prefer HTTPS with token (SSH port 2222 may be blocked); fall back to SSH $name = $repo['name'] ?? '';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; $nameLower = strtolower($name);
// Embed token in HTTPS URL for push auth $description = strtolower($repo['description'] ?? '');
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); $topics = $repo['topics'] ?? [];
echo " {$name} [{$platform}] ... "; if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
return 'crm-platform';
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform),
'package_type' => MokoStandardsParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/.mokostandards";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokostandards');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
rmTree($workDir);
continue;
} }
} if (in_array('dolibarr-platform', $topics)) {
return 'crm-platform';
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
} }
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
}
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla-template';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'waas-component';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) {
return 'joomla-template';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
} }
// Commit /**
$isNew = !$existingIsXml; * @return array{int, string}
$commitMsg = $isNew */
? 'chore: add XML .mokostandards manifest' private function safeExec(string $command, string $cwd = '.'): array
: 'chore: update .mokostandards to XML format'; {
if (!empty($legacyDeleted)) { $proc = proc_open(
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted); $command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd
);
if (!is_resource($proc)) {
return [1, "proc_open failed for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
} }
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); private function rmTree(string $dir): void
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); {
gitCmd($workDir, 'add', '.mokogitea/.mokostandards'); if (!is_dir($dir)) {
foreach ($legacyDeleted as $lf) { return;
gitCmd($workDir, 'add', $lf); }
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
} }
[$commitRet, $commitOut] = gitCmd($workDir, 'commit', '-m', $commitMsg); /**
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) { * @return array{int, string}
echo "SKIP (no changes)\n"; */
$stats['skipped']++; private function gitCmd(string $workDir, string ...$args): array
rmTree($workDir); {
continue; $cmd = 'git';
} foreach ($args as $a) {
if ($commitRet !== 0) { $cmd .= ' ' . escapeshellarg($a);
echo "FAIL (commit)\n"; }
fprintf(STDERR, " %s\n", $commitOut); return $this->safeExec($cmd, $workDir);
$stats['failed']++;
rmTree($workDir);
continue;
} }
[$pushRet, $pushOut] = gitCmd($workDir, 'push', 'origin', $defaultBranch); private function fetchRepos(string $url, string $org, string $token): array
if ($pushRet !== 0) { {
echo "FAIL (push)\n"; $repos = [];
fprintf(STDERR, " %s\n", $pushOut); $page = 1;
$stats['failed']++; do {
} else { $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
$action = $isNew ? 'CREATED' : 'UPDATED'; curl_setopt_array($ch, [
echo "{$action}\n"; CURLOPT_RETURNTRANSFER => true,
$stats[$isNew ? 'created' : 'updated']++; CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
} CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Cleanup if ($code !== 200) {
rmTree($workDir); $this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
} }
// Cleanup tmp base $app = new PushMokostandardsXmlCli();
@rmdir($tmpBase); exit($app->execute());
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
+14 -14
View File
@@ -9,9 +9,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Automation * DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoStandards.Scripts * INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/repo_cleanup.php * PATH: /automation/repo_cleanup.php
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs * BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
*/ */
@@ -21,7 +21,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory}; use MokoCli\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
/** /**
* Enterprise Repository Cleanup * Enterprise Repository Cleanup
@@ -38,15 +38,15 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAda
*/ */
class RepoCleanup extends CliFramework class RepoCleanup extends CliFramework
{ {
private const VERSION = '04.06.00'; private const VERSION = '09.23.00';
private const SYNC_PREFIX = 'chore/sync-mokostandards-'; private const SYNC_PREFIX = 'chore/sync-mokocli-';
private const CURRENT_BRANCH = 'chore/sync-mokostandards-v04.02.00'; private const CURRENT_BRANCH = 'chore/sync-mokocli-v04.02.00';
/** Workflow files that have been retired and should be deleted from governed repos. */ /** Workflow files that have been retired and should be deleted from governed repos. */
private const RETIRED_WORKFLOWS = [ private const RETIRED_WORKFLOWS = [
'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml', 'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml',
'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml', 'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml',
'flush-actions-cache.yml', 'mokostandards-script-runner.yml', 'unified-ci.yml', 'flush-actions-cache.yml', 'mokocli-script-runner.yml', 'unified-ci.yml',
'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml', 'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml',
'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml', 'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml',
'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml', 'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml',
@@ -98,7 +98,7 @@ class RepoCleanup extends CliFramework
} }
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION); $this->logMsg("🧹 mokocli Repository Cleanup v" . self::VERSION);
$this->logMsg("Organization: {$org}"); $this->logMsg("Organization: {$org}");
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH); $this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
if ($this->dryRun) { if ($this->dryRun) {
@@ -225,7 +225,7 @@ class RepoCleanup extends CliFramework
} }
$allRepos = $this->adapter->listOrgRepos($org, $skipArchived); $allRepos = $this->adapter->listOrgRepos($org, $skipArchived);
return array_filter($allRepos, fn($r) => !in_array($r['name'], ['MokoStandards', '.github-private'], true)); return array_filter($allRepos, fn($r) => !in_array($r['name'], ['mokocli', '.github-private'], true));
} }
// ─── Cleanup operations ────────────────────────────────────────────── // ─── Cleanup operations ──────────────────────────────────────────────
@@ -463,9 +463,9 @@ class RepoCleanup extends CliFramework
private function checkLabels(string $org, string $repo, array &$results): void private function checkLabels(string $org, string $repo, array &$results): void
{ {
try { try {
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards"); $this->api->get("/repos/{$org}/{$repo}/labels/mokocli");
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logMsg(" ⚠️ Missing 'mokostandards' label"); $this->logMsg(" ⚠️ Missing 'mokocli' label");
$results['labels_missing']++; $results['labels_missing']++;
$this->api->resetCircuitBreaker(); $this->api->resetCircuitBreaker();
} }
@@ -479,9 +479,9 @@ class RepoCleanup extends CliFramework
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$version = $m[1]; $version = $m[1];
// Check .mokostandards for the tracked MokoStandards version // Check manifest.xml for the tracked mokocli version
try { try {
$mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokostandards"); $mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokogitea/manifest.xml");
$mokoContent = base64_decode($mokoFile['content'] ?? ''); $mokoContent = base64_decode($mokoFile['content'] ?? '');
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) { if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
if ($vm[1] !== self::VERSION) { if ($vm[1] !== self::VERSION) {
+3 -3
View File
@@ -4,9 +4,9 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# DEFGROUP: MokoStandards.Automation.ServerAutoheal # DEFGROUP: MokoPlatform.Automation.ServerAutoheal
# INGROUP: MokoStandards.Automation # INGROUP: MokoPlatform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /automation/server-autoheal.sh # PATH: /automation/server-autoheal.sh
# BRIEF: Server auto-heal on unclean restart + split system/content backups # BRIEF: Server auto-heal on unclean restart + split system/content backups
# #
+633
View File
@@ -0,0 +1,633 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/update_dependencies.php
* VERSION: 09.38.00
* BRIEF: Cross-repo dependency update automation — scan, update, PR, auto-merge
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use MokoCli\{
ApiClient,
AuditLogger,
CheckpointManager,
CircuitBreakerOpen,
CliFramework,
Config,
GitPlatformAdapter,
PlatformAdapterFactory,
RateLimitExceeded
};
/**
* Cross-Repo Dependency Update Automation
*
* Scans org repos for outdated Composer/npm dependencies, creates PRs with
* changelogs, and optionally auto-merges safe patch updates.
*
* @see https://git.mokoconsulting.tech/MokoConsulting/mokocli/issues/149
*/
class UpdateDependencies extends CliFramework
{
public const VERSION = '01.00.00';
private const BRANCH_PREFIX = 'chore/deps-update';
private ApiClient $api;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private CheckpointManager $checkpoints;
/** Summary counters. */
private int $reposScanned = 0;
private int $reposUpdated = 0;
private int $prsCreated = 0;
private int $autoMerged = 0;
private int $reposFailed = 0;
protected function configure(): void
{
$this->setDescription('Cross-repo dependency update automation');
$this->addArgument('--org', 'Organization to scan', 'MokoConsulting');
$this->addArgument('--repos', 'Comma-separated list of specific repos', '');
$this->addArgument('--exclude', 'Comma-separated list of repos to exclude', '');
$this->addArgument('--skip-archived', 'Skip archived repositories', true);
$this->addArgument('--type', 'Dependency type: composer, npm, or all', 'all');
$this->addArgument('--patch-only', 'Only update patch versions (safe updates)', false);
$this->addArgument('--auto-merge', 'Auto-merge PRs with only patch updates', false);
$this->addArgument('--resume', 'Resume from checkpoint', false);
}
protected function run(): int
{
$this->log("Dependency Update Automation v" . self::VERSION, 'INFO');
if (!$this->initComponents()) {
return self::EXIT_FAILURE;
}
$org = $this->getArgument('--org', 'MokoConsulting');
$depType = strtolower($this->getArgument('--type', 'all'));
$patchOnly = $this->getArgument('--patch-only', false);
$autoMerge = $this->getArgument('--auto-merge', false);
// ── Gather repos ─────────────────────────────────────────────────
$repos = $this->gatherRepos($org);
if ($repos === null) {
return self::EXIT_FAILURE;
}
$total = count($repos);
$this->log("Found {$total} repositories to scan", 'INFO');
// ── Resume support ───────────────────────────────────────────────
$completed = [];
if ($this->getArgument('--resume', false)) {
$checkpoint = $this->checkpoints->load('deps_update');
if ($checkpoint) {
$completed = $checkpoint['completed'] ?? [];
$this->log("Resuming — skipping " . count($completed) . " already-processed repos", 'INFO');
}
}
// ── Process each repo ────────────────────────────────────────────
$this->section('Scanning repositories for outdated dependencies');
foreach ($repos as $i => $repo) {
$repoName = $repo['name'];
$this->progress($i + 1, $total, $repoName);
if (in_array($repoName, $completed, true)) {
continue;
}
try {
$this->processRepo($org, $repoName, $depType, $patchOnly, $autoMerge);
$completed[] = $repoName;
$this->checkpoints->save('deps_update', ['completed' => $completed]);
} catch (RateLimitExceeded $e) {
$this->log("Rate limit hit — checkpoint saved", 'WARNING');
break;
} catch (CircuitBreakerOpen $e) {
$this->log("Circuit breaker open — checkpoint saved", 'WARNING');
break;
} catch (\Exception $e) {
$this->log("Failed {$repoName}: {$e->getMessage()}", 'ERROR');
$this->reposFailed++;
}
}
$this->progress($total, $total, '', true);
// ── Summary ──────────────────────────────────────────────────────
$this->section('Summary');
$this->printSummary(
$this->reposScanned - $this->reposFailed,
$this->reposFailed,
$this->elapsed()
);
$this->log("Repos scanned: {$this->reposScanned}", 'INFO');
$this->log("Repos updated: {$this->reposUpdated}", 'INFO');
$this->log("PRs created: {$this->prsCreated}", 'INFO');
if ($autoMerge) {
$this->log("Auto-merged: {$this->autoMerged}", 'INFO');
}
if (count($completed) === $total) {
$this->checkpoints->clear('deps_update');
}
return $this->reposFailed > 0 ? self::EXIT_FAILURE : self::EXIT_SUCCESS;
}
// ── Component init ───────────────────────────────────────────────────
private function initComponents(): bool
{
try {
$config = new Config();
$this->api = new ApiClient($config);
$this->adapter = PlatformAdapterFactory::create($this->api, $config);
$this->logger = new AuditLogger();
$this->checkpoints = new CheckpointManager();
return true;
} catch (\Exception $e) {
$this->log("Failed to initialise: {$e->getMessage()}", 'ERROR');
return false;
}
}
// ── Repo gathering ───────────────────────────────────────────────────
private function gatherRepos(string $org): ?array
{
$specificRepos = array_filter(explode(',', $this->getArgument('--repos', '')));
$excludeRepos = array_filter(explode(',', $this->getArgument('--exclude', '')));
$skipArchived = $this->getArgument('--skip-archived', true);
// Default exclusions
$excludeRepos = array_merge($excludeRepos, [
'mokocli', '.mokogitea-private', 'org-profile',
]);
try {
$repos = $this->adapter->listOrgRepos($org, $skipArchived);
} catch (\Exception $e) {
$this->log("Failed to list repos: {$e->getMessage()}", 'ERROR');
return null;
}
if (!empty($specificRepos)) {
$repos = array_filter($repos, fn($r) => in_array($r['name'], $specificRepos, true));
}
if (!empty($excludeRepos)) {
$repos = array_filter($repos, fn($r) => !in_array($r['name'], $excludeRepos, true));
}
return array_values($repos);
}
// ── Per-repo processing ──────────────────────────────────────────────
private function processRepo(
string $org,
string $repoName,
string $depType,
bool $patchOnly,
bool $autoMerge
): void {
$this->reposScanned++;
$hasComposer = ($depType === 'all' || $depType === 'composer');
$hasNpm = ($depType === 'all' || $depType === 'npm');
$outdated = [];
// ── Composer ─────────────────────────────────────────────────
if ($hasComposer) {
$composerOutdated = $this->scanComposer($org, $repoName, $patchOnly);
if ($composerOutdated !== null) {
$outdated['composer'] = $composerOutdated;
}
}
// ── npm ──────────────────────────────────────────────────────
if ($hasNpm) {
$npmOutdated = $this->scanNpm($org, $repoName, $patchOnly);
if ($npmOutdated !== null) {
$outdated['npm'] = $npmOutdated;
}
}
if (empty($outdated)) {
return;
}
// Check if there's already an open deps PR
if ($this->hasExistingDepsPR($org, $repoName)) {
$this->log(" {$repoName}: existing deps PR found — skipping", 'INFO');
return;
}
$this->reposUpdated++;
// ── Create PR ────────────────────────────────────────────────
$totalUpdates = 0;
$allPatchOnly = true;
foreach ($outdated as $type => $packages) {
$totalUpdates += count($packages);
foreach ($packages as $pkg) {
if (!$this->isPatchUpdate($pkg['current'] ?? '', $pkg['latest'] ?? '')) {
$allPatchOnly = false;
}
}
}
$title = "chore(deps): update {$totalUpdates} " . ($totalUpdates === 1 ? 'dependency' : 'dependencies');
$body = $this->buildPrBody($repoName, $outdated);
$branch = self::BRANCH_PREFIX . '-' . date('Y-m-d');
if ($this->dryRun) {
$this->log("[dry-run] Would create PR in {$repoName}: {$title}", 'INFO');
foreach ($outdated as $type => $packages) {
foreach ($packages as $pkg) {
$this->log(" [{$type}] {$pkg['name']}: {$pkg['current']}{$pkg['latest']}", 'INFO');
}
}
return;
}
try {
// Clone repo, run updates, push branch
$prNumber = $this->cloneUpdateAndPR($org, $repoName, $branch, $title, $body, $outdated);
if ($prNumber > 0) {
$this->prsCreated++;
$this->log(" {$repoName}: PR #{$prNumber} created", 'INFO');
// Auto-merge if all updates are patch-level
if ($autoMerge && $allPatchOnly && $prNumber > 0) {
$this->tryAutoMerge($org, $repoName, $prNumber);
}
}
} catch (\Exception $e) {
$this->log(" {$repoName}: PR creation failed — {$e->getMessage()}", 'ERROR');
}
}
// ── Composer scanning ────────────────────────────────────────────────
private function scanComposer(string $org, string $repoName, bool $patchOnly): ?array
{
// Check if repo has composer.json
try {
$this->adapter->getFileContents($org, $repoName, 'composer.json');
} catch (\Exception $e) {
return null;
}
// Check if repo has composer.lock
try {
$this->adapter->getFileContents($org, $repoName, 'composer.lock');
} catch (\Exception $e) {
return null;
}
// Clone to temp dir and run composer outdated
$tmpDir = sys_get_temp_dir() . '/moko_deps_' . $repoName . '_' . getmypid();
@mkdir($tmpDir, 0700, true);
try {
$cloneUrl = $this->adapter->getCloneUrl($org, $repoName);
$cmd = sprintf(
'git clone --depth 1 --quiet %s %s 2>/dev/null',
escapeshellarg($cloneUrl),
escapeshellarg($tmpDir)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
return null;
}
// Run composer outdated
$flags = $patchOnly ? '--minor-only' : '';
$cmd = sprintf(
'composer outdated --format=json --no-interaction %s --working-dir=%s 2>/dev/null',
$flags,
escapeshellarg($tmpDir)
);
$json = shell_exec($cmd);
if ($json === null || $json === '') {
return null;
}
$data = json_decode($json, true);
$installed = $data['installed'] ?? [];
if (empty($installed)) {
return null;
}
$outdated = [];
foreach ($installed as $pkg) {
// Skip abandoned/dev packages
if (($pkg['abandoned'] ?? false) || str_starts_with($pkg['version'] ?? '', 'dev-')) {
continue;
}
$outdated[] = [
'name' => $pkg['name'] ?? '',
'current' => $pkg['version'] ?? '',
'latest' => $pkg['latest'] ?? '',
'status' => $pkg['latest-status'] ?? 'unknown',
];
}
return empty($outdated) ? null : $outdated;
} finally {
// Cleanup
if (is_dir($tmpDir)) {
exec(sprintf('rm -rf %s', escapeshellarg($tmpDir)));
}
}
}
// ── npm scanning ─────────────────────────────────────────────────────
private function scanNpm(string $org, string $repoName, bool $patchOnly): ?array
{
// Check if repo has package.json
try {
$this->adapter->getFileContents($org, $repoName, 'package.json');
} catch (\Exception $e) {
return null;
}
// Check for lock file
$hasLock = false;
foreach (['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'] as $lockFile) {
try {
$this->adapter->getFileContents($org, $repoName, $lockFile);
$hasLock = true;
break;
} catch (\Exception $e) {
// continue
}
}
if (!$hasLock) {
return null;
}
$tmpDir = sys_get_temp_dir() . '/moko_deps_npm_' . $repoName . '_' . getmypid();
@mkdir($tmpDir, 0700, true);
try {
$cloneUrl = $this->adapter->getCloneUrl($org, $repoName);
exec(sprintf('git clone --depth 1 --quiet %s %s 2>/dev/null',
escapeshellarg($cloneUrl), escapeshellarg($tmpDir)));
if (!file_exists("{$tmpDir}/package.json")) {
return null;
}
// Install deps first (needed for npm outdated)
exec(sprintf('cd %s && npm install --silent 2>/dev/null', escapeshellarg($tmpDir)));
$json = shell_exec(sprintf('cd %s && npm outdated --json 2>/dev/null', escapeshellarg($tmpDir)));
if ($json === null || $json === '' || $json === '{}') {
return null;
}
$data = json_decode($json, true);
if (!is_array($data) || empty($data)) {
return null;
}
$outdated = [];
foreach ($data as $name => $info) {
$current = $info['current'] ?? '';
$wanted = $info['wanted'] ?? '';
$latest = $info['latest'] ?? '';
$target = $patchOnly ? $wanted : $latest;
if ($current === $target || $target === '') {
continue;
}
$outdated[] = [
'name' => $name,
'current' => $current,
'latest' => $target,
'status' => ($current === $wanted) ? 'up-to-date' : 'outdated',
];
}
return empty($outdated) ? null : $outdated;
} finally {
if (is_dir($tmpDir)) {
exec(sprintf('rm -rf %s', escapeshellarg($tmpDir)));
}
}
}
// ── PR creation ──────────────────────────────────────────────────────
private function cloneUpdateAndPR(
string $org,
string $repoName,
string $branch,
string $title,
string $body,
array $outdated
): int {
$tmpDir = sys_get_temp_dir() . '/moko_deps_pr_' . $repoName . '_' . getmypid();
@mkdir($tmpDir, 0700, true);
try {
$cloneUrl = $this->adapter->getCloneUrl($org, $repoName);
exec(sprintf('git clone --quiet %s %s 2>/dev/null',
escapeshellarg($cloneUrl), escapeshellarg($tmpDir)));
// Create branch
exec(sprintf('git -C %s checkout -b %s 2>/dev/null',
escapeshellarg($tmpDir), escapeshellarg($branch)));
$updated = false;
// Run composer update if needed
if (isset($outdated['composer'])) {
$packages = array_column($outdated['composer'], 'name');
$cmd = sprintf(
'cd %s && composer update %s --no-interaction --quiet 2>/dev/null',
escapeshellarg($tmpDir),
implode(' ', array_map('escapeshellarg', $packages))
);
exec($cmd, $output, $exitCode);
if ($exitCode === 0) {
$updated = true;
}
}
// Run npm update if needed
if (isset($outdated['npm'])) {
$packages = array_column($outdated['npm'], 'name');
$cmd = sprintf(
'cd %s && npm update %s --save 2>/dev/null',
escapeshellarg($tmpDir),
implode(' ', array_map('escapeshellarg', $packages))
);
exec($cmd, $output, $exitCode);
if ($exitCode === 0) {
$updated = true;
}
}
if (!$updated) {
return 0;
}
// Commit and push
exec(sprintf('git -C %s config user.email "gitea-actions[bot]@mokoconsulting.tech"', escapeshellarg($tmpDir)));
exec(sprintf('git -C %s config user.name "gitea-actions[bot]"', escapeshellarg($tmpDir)));
exec(sprintf('git -C %s add -A', escapeshellarg($tmpDir)));
// Check if there are actual changes
exec(sprintf('git -C %s diff --cached --quiet', escapeshellarg($tmpDir)), $output, $diffExit);
if ($diffExit === 0) {
return 0; // No changes
}
exec(sprintf('git -C %s commit -m %s',
escapeshellarg($tmpDir),
escapeshellarg($title . " [skip ci]")));
exec(sprintf('git -C %s push origin %s 2>/dev/null',
escapeshellarg($tmpDir), escapeshellarg($branch)), $output, $pushExit);
if ($pushExit !== 0) {
$this->log(" {$repoName}: push failed", 'ERROR');
return 0;
}
// Create PR via API
$defaultBranch = $this->getDefaultBranch($org, $repoName);
$pr = $this->adapter->createPullRequest(
$org, $repoName, $title, $branch, $defaultBranch, $body, [
'labels' => ['dependencies'],
]
);
return (int) ($pr['number'] ?? 0);
} finally {
if (is_dir($tmpDir)) {
exec(sprintf('rm -rf %s', escapeshellarg($tmpDir)));
}
}
}
// ── Auto-merge ───────────────────────────────────────────────────────
private function tryAutoMerge(string $org, string $repoName, int $prNumber): void
{
try {
$this->api->put(
"/repos/{$org}/{$repoName}/pulls/{$prNumber}/merge",
['Do' => 'squash', 'merge_message_field' => 'chore(deps): auto-merge patch updates']
);
$this->autoMerged++;
$this->log(" {$repoName}: PR #{$prNumber} auto-merged", 'INFO');
} catch (\Exception $e) {
$this->log(" {$repoName}: auto-merge failed — {$e->getMessage()}", 'WARNING');
}
}
// ── Helpers ───────────────────────────────────────────────────────────
private function hasExistingDepsPR(string $org, string $repoName): bool
{
try {
$prs = $this->adapter->listPullRequests($org, $repoName, ['state' => 'open']);
foreach ($prs as $pr) {
if (str_starts_with($pr['head']['ref'] ?? '', self::BRANCH_PREFIX)) {
return true;
}
}
} catch (\Exception $e) {
// Ignore — proceed with creating PR
}
return false;
}
private function getDefaultBranch(string $org, string $repoName): string
{
try {
$repo = $this->api->get("/repos/{$org}/{$repoName}");
return $repo['default_branch'] ?? 'main';
} catch (\Exception $e) {
return 'main';
}
}
private function isPatchUpdate(string $current, string $latest): bool
{
$cur = explode('.', ltrim($current, 'v'));
$lat = explode('.', ltrim($latest, 'v'));
if (count($cur) < 3 || count($lat) < 3) {
return false;
}
// Same major and minor, only patch differs
return $cur[0] === $lat[0] && $cur[1] === $lat[1] && $cur[2] !== $lat[2];
}
private function buildPrBody(string $repoName, array $outdated): string
{
$lines = [
"## Dependency Updates",
"",
"**Repository**: `{$repoName}`",
"**Scanned**: " . date('Y-m-d H:i:s'),
"",
];
foreach ($outdated as $type => $packages) {
$lines[] = "### " . ucfirst($type);
$lines[] = "";
$lines[] = "| Package | Current | Latest | Type |";
$lines[] = "|---------|---------|--------|------|";
foreach ($packages as $pkg) {
$updateType = $this->isPatchUpdate($pkg['current'], $pkg['latest']) ? 'patch' : 'minor/major';
$lines[] = "| `{$pkg['name']}` | {$pkg['current']} | {$pkg['latest']} | {$updateType} |";
}
$lines[] = "";
}
$lines[] = "---";
$lines[] = "*Auto-generated by `moko deps:update`*";
return implode("\n", $lines);
}
}
$script = new UpdateDependencies('update_dependencies', 'Cross-repo dependency update automation');
exit($script->execute());
+124 -44
View File
@@ -9,36 +9,32 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.CLI * DEFGROUP: MokoCli.CLI
* INGROUP: MokoStandards * INGROUP: MokoCli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /bin/moko * PATH: /bin/moko
* BRIEF: Unified CLI dispatcher — run any MokoStandards script without needing GitHub Actions * BRIEF: Unified CLI dispatcher — run any MokoCli script without needing GitHub Actions
* *
* USAGE * USAGE
* php bin/moko <command> [options] (all platforms) * php bin/moko <command> [options] (all platforms)
* ./bin/moko <command> [options] (Unix, after: chmod +x bin/moko) * ./bin/moko <command> [options] (Unix, after: chmod +x bin/moko)
* *
* COMMANDS * COMMANDS (run `php bin/moko list` for the full list — 97 commands)
* sync Bulk-sync MokoStandards to organisation repos
* health Full repository health check (runs most validators)
* inventory Refresh docs/reference/REPOSITORY_INVENTORY.md
* *
* check:syntax PHP syntax check (php -l) on all tracked .php files * Automation sync, automation:cleanup, automation:migrate-gitea
* check:version Verify VERSION fields and badges match composer.json * Validation health, detect, drift, check:syntax, check:version, ...
* check:changelog Validate CHANGELOG.md format * Release release, release:joomla, release:create, release:publish, ...
* check:structure Verify required root files and directories * Version version:read, version:bump, version:auto-bump, ...
* check:headers Check SPDX-License-Identifier presence in source files * Build build:package, build:joomla, build:updates-xml, ...
* check:secrets Scan for leaked credentials / API keys * Deploy deploy:joomla, deploy:dolibarr, deploy:sftp, deploy:rollback, ...
* check:tabs Detect tab characters in YAML files * Repository repo:create, repo:archive, repo:rename-branch, repo:reset-dev, ...
* check:paths Detect backslash path separators in PHP source * Bulk Operations bulk:push-workflow, bulk:push-manifest, bulk:template-joomla, ...
* check:xml Validate XML files are well-formed * Maintenance maintenance:labels, maintenance:rotate-secrets, maintenance:pin-shas, ...
* check:enterprise Full enterprise-readiness check (headers, strict types, PSR-12) * Fix fix:line-endings, fix:tabs, fix:trailing, fix:permissions
* check:dolibarr Validate Dolibarr module directory structure * Monitoring dashboard, grafana, client:inventory, client:health-check
* check:joomla Validate Joomla XML manifest * Platform platform:detect, manifest:read, manifest:element
* check:language Validate Joomla/Dolibarr .ini language files * Wiki wiki:sync
* detect Auto-detect repository platform type * Badges badge:update
* drift Scan org repos for drift from MokoStandards templates
* *
* COMMON OPTIONS (passed through to each script) * COMMON OPTIONS (passed through to each script)
* --path <dir> Repository root to check (default: .) * --path <dir> Repository root to check (default: .)
@@ -88,11 +84,23 @@ require_once $autoloader;
* All paths are relative to the repo root. * All paths are relative to the repo root.
*/ */
const COMMAND_MAP = [ const COMMAND_MAP = [
// Audit
'audit:query' => 'cli/audit_query.php',
// Automation // Automation
'sync' => 'automation/bulk_sync.php', 'sync' => 'automation/bulk_sync.php',
'deps:update' => 'automation/update_dependencies.php',
'automation:cleanup' => 'automation/repo_cleanup.php',
'automation:migrate-gitea' => 'automation/migrate_to_gitea.php',
// Maintenance // Maintenance
'inventory' => 'maintenance/update_repo_inventory.php', 'inventory' => 'maintenance/update_repo_inventory.php',
'maintenance:pin-shas' => 'maintenance/pin_action_shas.php',
'maintenance:inventory' => 'maintenance/repo_inventory.php',
'maintenance:rotate-secrets' => 'maintenance/rotate_secrets.php',
'maintenance:labels' => 'maintenance/setup_labels.php',
'maintenance:sync-dolibarr' => 'maintenance/sync_dolibarr_readmes.php',
'maintenance:update-shas' => 'maintenance/update_sha_hashes.php',
// Validation — general // Validation — general
'health' => 'validate/check_repo_health.php', 'health' => 'validate/check_repo_health.php',
@@ -108,11 +116,13 @@ const COMMAND_MAP = [
'check:enterprise' => 'validate/check_enterprise_readiness.php', 'check:enterprise' => 'validate/check_enterprise_readiness.php',
// Validation — platform-specific // Validation — platform-specific
'check:dolibarr' => 'validate/check_dolibarr_module.php', 'check:dolibarr' => 'validate/check_dolibarr_module.php',
'check:joomla' => 'validate/check_joomla_manifest.php', 'check:joomla' => 'validate/check_joomla_manifest.php',
'check:language' => 'validate/check_language_structure.php', 'check:joomla-compat' => 'cli/joomla_compat_check.php',
'check:client' => 'validate/check_client_theme.php', 'check:language' => 'validate/check_language_structure.php',
'check:wiki' => 'validate/check_wiki_health.php', 'check:client' => 'validate/check_client_theme.php',
'check:theme' => 'cli/theme_lint.php',
'check:wiki' => 'validate/check_wiki_health.php',
// Detection // Detection
'detect' => 'validate/auto_detect_platform.php', 'detect' => 'validate/auto_detect_platform.php',
@@ -124,13 +134,18 @@ const COMMAND_MAP = [
'release' => 'cli/release.php', 'release' => 'cli/release.php',
'release:notes' => 'cli/release_notes.php', 'release:notes' => 'cli/release_notes.php',
'release:validate' => 'cli/release_validate.php', 'release:validate' => 'cli/release_validate.php',
'manifest:element' => 'cli/manifest_element.php',
'release:cascade' => 'cli/release_cascade.php', 'release:cascade' => 'cli/release_cascade.php',
'release:promote' => 'cli/release_promote.php', 'release:promote' => 'cli/release_promote.php',
'release:create' => 'cli/release_create.php', 'release:create' => 'cli/release_create.php',
'release:manage' => 'cli/release_manage.php', 'release:manage' => 'cli/release_manage.php',
'release:mirror' => 'cli/release_mirror.php', 'release:mirror' => 'cli/release_mirror.php',
'release:package' => 'cli/release_package.php', 'release:package' => 'cli/release_package.php',
'release:joomla' => 'cli/joomla_release.php',
'release:body-update' => 'cli/release_body_update.php',
'release:publish' => 'cli/release_publish.php',
'release:verify' => 'cli/release_verify.php',
'release:gen-dolibarr' => 'release/generate_dolibarr_version_txt.php',
'release:gen-joomla' => 'release/generate_joomla_update_xml.php',
// Changelog // Changelog
'changelog:promote' => 'cli/changelog_promote.php', 'changelog:promote' => 'cli/changelog_promote.php',
@@ -143,31 +158,76 @@ const COMMAND_MAP = [
'version:propagate' => 'maintenance/update_version_from_readme.php', 'version:propagate' => 'maintenance/update_version_from_readme.php',
'version:set-platform' => 'cli/version_set_platform.php', 'version:set-platform' => 'cli/version_set_platform.php',
'version:reset-dev' => 'cli/version_reset_dev.php', 'version:reset-dev' => 'cli/version_reset_dev.php',
'version:auto-bump' => 'cli/version_auto_bump.php',
'version:bump-remote' => 'cli/version_bump_remote.php',
// Build & package // Build & package
'build:package' => 'cli/package_build.php', 'build:package' => 'cli/package_build.php',
'build:joomla' => 'cli/joomla_build.php', 'build:joomla' => 'cli/joomla_build.php',
'build:updates-xml' => 'cli/updates_xml_build.php', 'build:updates-xml' => 'cli/updates_xml_build.php',
'build:updates-xml-sync' => 'cli/updates_xml_sync.php',
// Platform detection // Platform detection & manifest
'platform:detect' => 'cli/platform_detect.php', 'platform:detect' => 'cli/platform_detect.php',
'manifest:read' => 'cli/manifest_read.php', 'manifest:read' => 'cli/manifest_read.php',
'manifest:element' => 'cli/manifest_element.php',
// Repository management // Repository management
'repo:create' => 'cli/create_repo.php', 'repo:create' => 'cli/create_repo.php',
'repo:create-project' => 'cli/create_project.php',
'repo:archive' => 'cli/archive_repo.php', 'repo:archive' => 'cli/archive_repo.php',
'repo:scaffold-client' => 'cli/scaffold_client.php', 'repo:scaffold-client' => 'cli/scaffold_client.php',
'repo:provision' => 'cli/client_provision.php', 'repo:provision' => 'cli/client_provision.php',
'repo:wizard' => 'cli/repo_wizard.php',
'repo:rename-branch' => 'cli/branch_rename.php',
'repo:reset-dev' => 'cli/dev_branch_reset.php',
// Bulk operations // Bulk operations
'bulk:push-workflow' => 'cli/bulk_workflow_push.php', 'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
'bulk:trigger' => 'cli/bulk_workflow_trigger.php', 'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
'bulk:sync-rulesets' => 'cli/sync_rulesets.php', 'bulk:sync-rulesets' => 'cli/sync_rulesets.php',
'bulk:push-files' => 'automation/push_files.php',
'bulk:push-manifest' => 'automation/push_manifest_xml.php',
'bulk:push-mokostandards' => 'automation/push_mokostandards_xml.php',
'bulk:enrich-manifest' => 'automation/enrich_manifest_xml.php',
'bulk:enrich-mokostandards' => 'automation/enrich_mokostandards_xml.php',
'bulk:template-joomla' => 'automation/bulk_joomla_template.php',
// Deploy
'deploy:joomla' => 'cli/deploy_joomla.php',
'deploy:joomla-legacy' => 'deploy/deploy-joomla.php',
'deploy:dolibarr' => 'deploy/deploy-dolibarr.php',
'deploy:sftp' => 'deploy/deploy-sftp.php',
'deploy:backup' => 'deploy/backup-before-deploy.php',
'deploy:health-check' => 'deploy/health-check.php',
'deploy:verify' => 'deploy/deploy-and-verify.php',
'deploy:rollback' => 'deploy/rollback-joomla.php',
'deploy:sync' => 'deploy/sync-joomla.php',
// Fix / auto-remediation
'fix:line-endings' => 'fix/fix_line_endings.php',
'fix:tabs' => 'fix/fix_tabs.php',
'fix:trailing' => 'fix/fix_trailing_spaces.php',
'fix:permissions' => 'fix/fix_permissions.php',
// Monitoring & dashboards // Monitoring & dashboards
'dashboard' => 'cli/client_dashboard.php', 'dashboard' => 'cli/client_dashboard.php',
'grafana' => 'cli/grafana_dashboard.php', 'grafana' => 'cli/grafana_dashboard.php',
'client:inventory' => 'cli/client_inventory.php', 'client:inventory' => 'cli/client_inventory.php',
'client:health-check' => 'cli/client_health_check.php',
// Badge & wiki
'badge:update' => 'cli/badge_update.php',
'wiki:sync' => 'cli/wiki_sync.php',
// Licensing
'license' => 'cli/license_manage.php',
// Security
'security:advisories' => 'security/advisory_scan.php',
// Shell completion
'completion' => 'cli/completion.php',
// Module validation // Module validation
'validate:module' => 'bin/validate-module', 'validate:module' => 'bin/validate-module',
@@ -197,16 +257,28 @@ if ($command === 'list' || $command === 'commands') {
// ── Dispatch ────────────────────────────────────────────────────────────────── // ── Dispatch ──────────────────────────────────────────────────────────────────
if (!array_key_exists($command, COMMAND_MAP)) { $scriptRelative = null;
if (array_key_exists($command, COMMAND_MAP)) {
$scriptRelative = COMMAND_MAP[$command];
} else {
// Fall back to plugin-provided commands before giving up.
$pluginCommands = loadPluginCommands();
if (isset($pluginCommands[$command]) && !empty($pluginCommands[$command]['script'])) {
$scriptRelative = $pluginCommands[$command]['script'];
}
}
if ($scriptRelative === null) {
fwrite(STDERR, "Error: Unknown command '{$command}'\n\n"); fwrite(STDERR, "Error: Unknown command '{$command}'\n\n");
printCommandList(); printCommandList();
exit(2); exit(2);
} }
$scriptPath = $repoRoot . '/' . COMMAND_MAP[$command]; $scriptPath = $repoRoot . '/' . $scriptRelative;
if (!is_file($scriptPath)) { if (!is_file($scriptPath)) {
fwrite(STDERR, "Error: Script not found: " . COMMAND_MAP[$command] . "\n"); fwrite(STDERR, "Error: Script not found: {$scriptRelative}\n");
fwrite(STDERR, "Ensure the repository is complete and run: composer install\n"); fwrite(STDERR, "Ensure the repository is complete and run: composer install\n");
exit(2); exit(2);
} }
@@ -226,10 +298,10 @@ function printHelp(): void
{ {
echo <<<'HELP' echo <<<'HELP'
╔══════════════════════════════════════════════════════════╗ ╔══════════════════════════════════════════════════════════╗
║ MokoStandards CLI (bin/moko) ║ ║ MokoCli CLI (bin/moko) ║
╚══════════════════════════════════════════════════════════╝ ╚══════════════════════════════════════════════════════════╝
Run any MokoStandards script locally without GitHub Actions. Run any MokoCli script locally without GitHub Actions.
USAGE USAGE
php bin/moko <command> [options] (all platforms) php bin/moko <command> [options] (all platforms)
@@ -268,6 +340,12 @@ function printCommandList(): void
'bulk' => 'Bulk Operations', 'bulk' => 'Bulk Operations',
'client' => 'Client Management', 'client' => 'Client Management',
'validate' => 'Module Validation', 'validate' => 'Module Validation',
'deploy' => 'Deploy',
'fix' => 'Fix / Auto-remediation',
'maintenance' => 'Maintenance',
'automation' => 'Automation',
'badge' => 'Badges',
'wiki' => 'Wiki',
default => ucfirst($prefix), default => ucfirst($prefix),
}; };
} else { } else {
@@ -277,6 +355,8 @@ function printCommandList(): void
'health' => 'Validation', 'health' => 'Validation',
'detect', 'drift' => 'Validation', 'detect', 'drift' => 'Validation',
'dashboard', 'grafana' => 'Monitoring', 'dashboard', 'grafana' => 'Monitoring',
'release' => 'Release',
'license' => 'Licensing',
default => 'Other', default => 'Other',
}; };
} }
@@ -323,7 +403,7 @@ function loadPluginCommands(): array
$commands = []; $commands = [];
foreach (glob("{$pluginDir}/*Plugin.php") as $file) { foreach (glob("{$pluginDir}/*Plugin.php") as $file) {
$className = 'MokoEnterprise\\Plugins\\' $className = 'MokoCli\\Plugins\\'
. pathinfo($file, PATHINFO_FILENAME); . pathinfo($file, PATHINFO_FILENAME);
if (!class_exists($className)) { if (!class_exists($className)) {
-19
View File
@@ -1,19 +0,0 @@
# Build Index: /api/build
## Purpose
This folder contains build system management and compilation scripts.
## Quick Links
- [README](./README.md) - Build scripts documentation
## Scripts
- [moko-make](./moko-make) - Build system wrapper
- [resolve_makefile.py](./resolve_makefile.py) - Makefile resolution
## Metadata
- **Document Type:** index
- **Auto-generated:** This file is manually maintained for ignored directory
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env bash
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Moko Build Wrapper
# Automatically finds and uses appropriate Makefile from MokoStandards
set -e
# Colors
COLOR_RESET="\033[0m"
COLOR_GREEN="\033[32m"
COLOR_BLUE="\033[34m"
COLOR_RED="\033[31m"
# Find MokoStandards root
find_mokostandards() {
# Check environment variable
if [ -n "$MOKOSTANDARDS_ROOT" ] && [ -d "$MOKOSTANDARDS_ROOT/templates/build" ]; then
echo "$MOKOSTANDARDS_ROOT"
return 0
fi
# Check adjacent directories
if [ -d "../MokoStandards/templates/build" ]; then
echo "$(cd ../MokoStandards && pwd)"
return 0
fi
if [ -d "../../MokoStandards/templates/build" ]; then
echo "$(cd ../../MokoStandards && pwd)"
return 0
fi
# Check home directory
if [ -d "$HOME/.mokostandards/templates/build" ]; then
echo "$HOME/.mokostandards"
return 0
fi
# Check system location
if [ -d "/opt/mokostandards/templates/build" ]; then
echo "/opt/mokostandards"
return 0
fi
return 1
}
# Find appropriate Makefile
find_makefile() {
# Check for local Makefile
if [ -f "Makefile" ]; then
echo "Makefile"
return 0
fi
# Check for .moko/Makefile
if [ -f ".moko/Makefile" ]; then
echo ".moko/Makefile"
return 0
fi
# Find MokoStandards
MOKO_ROOT=$(find_mokostandards)
if [ $? -ne 0 ]; then
echo -e "${COLOR_RED}✗${COLOR_RESET} MokoStandards repository not found" >&2
echo -e "${COLOR_BLUE}Hint:${COLOR_RESET} Set MOKOSTANDARDS_ROOT or clone adjacent" >&2
return 1
fi
# Detect project type
if [ -d "core/modules" ] && ls core/modules/mod*.class.php >/dev/null 2>&1; then
echo "$MOKO_ROOT/templates/build/dolibarr/Makefile"
return 0
fi
# Check for Joomla XML files
shopt -s nullglob # Prevent glob expansion if no matches
for xml in *.xml; do
if [ -f "$xml" ]; then
if grep -q 'type="component"' "$xml" 2>/dev/null; then
echo "$MOKO_ROOT/templates/build/joomla/Makefile.component"
return 0
elif grep -q 'type="module"' "$xml" 2>/dev/null; then
echo "$MOKO_ROOT/templates/build/joomla/Makefile.module"
return 0
elif grep -q 'type="plugin"' "$xml" 2>/dev/null; then
echo "$MOKO_ROOT/templates/build/joomla/Makefile.plugin"
return 0
fi
fi
done
shopt -u nullglob
echo -e "${COLOR_RED}✗${COLOR_RESET} Could not detect project type" >&2
return 1
}
# Main execution
MAKEFILE=$(find_makefile)
if [ $? -ne 0 ]; then
exit 1
fi
# Show which Makefile we're using
if [[ "$MAKEFILE" == *"MokoStandards"* ]] || [[ "$MAKEFILE" == *".mokostandards"* ]]; then
echo -e "${COLOR_BLUE}${COLOR_RESET} Using MokoStandards template"
fi
# Run make with the found Makefile
exec make -f "$MAKEFILE" "$@"
+147 -123
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,139 +8,162 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/archive_repo.php * PATH: /cli/archive_repo.php
* BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def * BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def
*
* USAGE
* php cli/archive_repo.php --repo MokoOldModule
* php cli/archive_repo.php --repo MokoOldModule --dry-run
* php cli/archive_repo.php --repo MokoOldModule --skip-close # Archive only, keep issues open
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\Config; use MokoCli\CliFramework;
use MokoEnterprise\PlatformAdapterFactory; use MokoCli\Config;
use MokoCli\PlatformAdapterFactory;
$dryRun = in_array('--dry-run', $argv); class ArchiveRepoCli extends CliFramework
$skipClose = in_array('--skip-close', $argv); {
protected function configure(): void
{
$this->setDescription('Gracefully retire a governed repository — archive, close issues/PRs, remove sync def');
$this->addArgument('--repo', 'Repository name to archive', '');
$this->addArgument('--skip-close', 'Archive only, keep issues open', false);
}
$repoName = null; protected function run(): int
{
$repoName = $this->getArgument('--repo');
$skipClose = $this->getArgument('--skip-close');
foreach ($argv as $i => $arg) { if (empty($repoName)) {
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; } $this->log('ERROR', 'Usage: php archive_repo.php --repo <RepoName> [--skip-close] [--dry-run]');
return 2;
}
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $config->getString(
$adapter->getPlatformName() . '.organization',
'mokoconsulting-tech'
);
$platformName = $adapter->getPlatformName();
echo "Archiving repository: {$org}/{$repoName} (on {$platformName})\n\n";
// -- Step 1: Verify repo exists --
echo "Step 1: Verifying repository...\n";
try {
$repoData = $adapter->getRepo($org, $repoName);
} catch (\Exception $e) {
$this->log('ERROR', "Repository {$org}/{$repoName} not found: " . $e->getMessage());
return 1;
}
if ($repoData['archived'] ?? false) {
echo " Already archived — nothing to do\n";
return 0;
}
echo " Found: " . ($repoData['html_url'] ?? "{$org}/{$repoName}") . "\n";
// -- Step 2: Close all open PRs --
if (!$skipClose) {
echo "Step 2: Closing open pull requests...\n";
$prs = $adapter->listPullRequests($org, $repoName, ['state' => 'open']);
$prCount = count($prs);
echo " Found {$prCount} open PRs\n";
foreach ($prs as $pr) {
$num = $pr['number'];
if (!$this->dryRun) {
$adapter->updatePullRequest($org, $repoName, $num, ['state' => 'closed']);
$adapter->addIssueComment(
$org,
$repoName,
$num,
"Closed as part of repository archival. This repository is being retired.\n\n*Auto-closed by `archive_repo.php`*"
);
}
echo " Closed PR #{$num}: {$pr['title']}\n";
}
// -- Step 3: Close all open issues --
echo "Step 3: Closing open issues...\n";
$issues = $adapter->listIssues($org, $repoName, ['state' => 'open']);
$issues = array_filter($issues, fn($i) => !isset($i['pull_request']));
$issueCount = count($issues);
echo " Found {$issueCount} open issues\n";
foreach ($issues as $issue) {
$num = $issue['number'];
if (!$this->dryRun) {
$adapter->closeIssue($org, $repoName, $num);
$adapter->addIssueComment(
$org,
$repoName,
$num,
"Closed as part of repository archival.\n\n*Auto-closed by `archive_repo.php`*"
);
}
echo " Closed issue #{$num}: {$issue['title']}\n";
}
} else {
echo "Step 2-3: Skipping issue/PR closure (--skip-close)\n";
}
// -- Step 4: Archive the repository --
echo "Step 4: Archiving repository...\n";
if (!$this->dryRun) {
try {
$adapter->archiveRepo($org, $repoName);
echo " Repository archived\n";
} catch (\Exception $e) {
echo " Failed to archive: " . $e->getMessage() . "\n";
}
} else {
echo " (dry-run) would archive {$org}/{$repoName}\n";
}
// -- Step 5: (removed — sync definitions no longer used) --
// -- Step 6: Create archival record --
echo "Step 6: Creating archival record...\n";
if (!$this->dryRun) {
$now = gmdate('Y-m-d H:i:s') . ' UTC';
try {
$issue = $adapter->createIssue(
$org,
'mokocli',
"chore: archived repository {$repoName}",
"## Repository Archived\n\n"
. "**Repository:** `{$org}/{$repoName}`\n"
. "**Archived:** {$now}\n"
. "**Platform:** {$platformName}\n"
. "**Sync definition removed:** yes\n\n"
. "---\n"
. "*Auto-created by `archive_repo.php`*\n",
[
'labels' => ['type: chore', 'automation', 'archived'],
'assignees' => ['jmiller'],
]
);
if (isset($issue['number'])) {
echo " Archival record: mokocli#{$issue['number']}\n";
}
} catch (\Exception $e) {
echo " Warning: could not create archival record: " . $e->getMessage() . "\n";
}
} else {
echo " (dry-run) would create archival record issue\n";
}
echo "\n" . str_repeat('-', 50) . "\n";
echo "Repository {$org}/{$repoName} archived successfully\n";
return 0;
}
} }
if (!$repoName) { $app = new ArchiveRepoCli();
fwrite(STDERR, "Usage: php archive_repo.php --repo <RepoName> [--skip-close] [--dry-run]\n"); exit($app->execute());
exit(2);
}
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $config->getString(
$adapter->getPlatformName() . '.organization',
'mokoconsulting-tech'
);
$repoRoot = dirname(__DIR__, 2);
$platformName = $adapter->getPlatformName();
echo "Archiving repository: {$org}/{$repoName} (on {$platformName})\n\n";
// ── Step 1: Verify repo exists ──────────────────────────────────────────
echo "Step 1: Verifying repository...\n";
try {
$repoData = $adapter->getRepo($org, $repoName);
} catch (\Exception $e) {
fwrite(STDERR, " Repository {$org}/{$repoName} not found: " . $e->getMessage() . "\n");
exit(1);
}
if ($repoData['archived'] ?? false) {
echo " Already archived — nothing to do\n";
exit(0);
}
echo " Found: " . ($repoData['html_url'] ?? "{$org}/{$repoName}") . "\n";
// ── Step 2: Close all open PRs ──────────────────────────────────────────
if (!$skipClose) {
echo "Step 2: Closing open pull requests...\n";
$prs = $adapter->listPullRequests($org, $repoName, ['state' => 'open']);
$prCount = count($prs);
echo " Found {$prCount} open PRs\n";
foreach ($prs as $pr) {
$num = $pr['number'];
if (!$dryRun) {
$adapter->updatePullRequest($org, $repoName, $num, ['state' => 'closed']);
$adapter->addIssueComment($org, $repoName, $num,
"Closed as part of repository archival. This repository is being retired.\n\n*Auto-closed by `archive_repo.php`*"
);
}
echo " Closed PR #{$num}: {$pr['title']}\n";
}
// ── Step 3: Close all open issues ───────────────────────────────────
echo "Step 3: Closing open issues...\n";
$issues = $adapter->listIssues($org, $repoName, ['state' => 'open']);
$issues = array_filter($issues, fn($i) => !isset($i['pull_request']));
$issueCount = count($issues);
echo " Found {$issueCount} open issues\n";
foreach ($issues as $issue) {
$num = $issue['number'];
if (!$dryRun) {
$adapter->closeIssue($org, $repoName, $num);
$adapter->addIssueComment($org, $repoName, $num,
"Closed as part of repository archival.\n\n*Auto-closed by `archive_repo.php`*"
);
}
echo " Closed issue #{$num}: {$issue['title']}\n";
}
} else {
echo "Step 2-3: Skipping issue/PR closure (--skip-close)\n";
}
// ── Step 4: Archive the repository ──────────────────────────────────────
echo "Step 4: Archiving repository...\n";
if (!$dryRun) {
try {
$adapter->archiveRepo($org, $repoName);
echo " Repository archived\n";
} catch (\Exception $e) {
echo " Failed to archive: " . $e->getMessage() . "\n";
}
} else {
echo " (dry-run) would archive {$org}/{$repoName}\n";
}
// ── Step 5: (removed — sync definitions no longer used) ─────────────────
// ── Step 6: Create archival record ──────────────────────────────────────
echo "Step 6: Creating archival record...\n";
if (!$dryRun) {
$now = gmdate('Y-m-d H:i:s') . ' UTC';
try {
$issue = $adapter->createIssue($org, 'MokoStandards',
"chore: archived repository {$repoName}",
"## Repository Archived\n\n**Repository:** `{$org}/{$repoName}`\n**Archived:** {$now}\n**Platform:** {$platformName}\n**Sync definition removed:** yes\n\n---\n*Auto-created by `archive_repo.php`*\n",
[
'labels' => ['type: chore', 'automation', 'archived'],
'assignees' => ['jmiller'],
]
);
if (isset($issue['number'])) { echo " Archival record: MokoStandards#{$issue['number']}\n"; }
} catch (\Exception $e) {
echo " Warning: could not create archival record: " . $e->getMessage() . "\n";
}
} else {
echo " (dry-run) would create archival record issue\n";
}
echo "\n" . str_repeat('-', 50) . "\n";
echo "Repository {$org}/{$repoName} archived successfully\n";
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Enterprise.CLI
* INGROUP: MokoPlatform.Enterprise
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/audit_query.php
* BRIEF: Search, filter, and export audit logs
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
/**
* CLI tool to search, filter, and export audit logs.
*
* Reads JSONL audit log files from var/logs/audit/ and provides
* filtering by service, user, event type, level, and date range.
*
* @since 09.01.00
*/
class AuditQueryCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Search, filter, and export audit logs');
$this->addArgument('--path', 'Repository root (for var/logs/audit/)', '.');
$this->addArgument('--log-dir', 'Custom log directory', '');
$this->addArgument('--service', 'Filter by service name', '');
$this->addArgument('--user', 'Filter by user', '');
$this->addArgument('--event', 'Filter by event type', '');
$this->addArgument('--level', 'Filter by log level (info/warning/error)', '');
$this->addArgument('--since', 'Show entries since date (YYYY-MM-DD)', '');
$this->addArgument('--until', 'Show entries until date (YYYY-MM-DD)', '');
$this->addArgument('--limit', 'Max entries to show', '50');
$this->addArgument('--format', 'Output format: table, json, jsonl', 'table');
$this->addArgument('--tail', 'Show last N entries (like tail)', false);
$this->addArgument('--stats', 'Show summary statistics instead of entries', false);
}
protected function run(): int
{
$logDir = $this->resolveLogDir();
if ($logDir === null) {
return self::EXIT_NOT_FOUND;
}
$files = $this->findLogFiles($logDir);
if (empty($files)) {
$this->log('WARNING', 'No audit log files found in ' . $logDir);
return self::EXIT_SUCCESS;
}
$this->log('DEBUG', sprintf('Found %d log file(s) in %s', count($files), $logDir));
$entries = $this->loadEntries($files);
$entries = $this->filterEntries($entries);
// Sort by timestamp descending (newest first).
usort($entries, static function (array $a, array $b): int {
return ($b['timestamp'] ?? '') <=> ($a['timestamp'] ?? '');
});
// Stats mode — show aggregated counts.
if ($this->getArgument('--stats')) {
return $this->showStats($entries);
}
// Apply limit.
$limit = (int) $this->getArgument('--limit', '50');
if ($limit > 0 && count($entries) > $limit) {
$entries = array_slice($entries, 0, $limit);
}
if (empty($entries)) {
$this->log('INFO', 'No entries match the given filters.');
return self::EXIT_SUCCESS;
}
return $this->outputEntries($entries);
}
/**
* Resolve the audit log directory path.
*
* @return string|null Resolved directory path or null if not found.
*/
private function resolveLogDir(): ?string
{
$customDir = $this->getArgument('--log-dir');
if ($customDir !== '' && $customDir !== null) {
$logDir = (string) $customDir;
} else {
$repoPath = (string) $this->getArgument('--path', '.');
$logDir = rtrim($repoPath, '/\\') . '/var/logs/audit';
}
if (!is_dir($logDir)) {
$this->log('ERROR', 'Audit log directory not found: ' . $logDir);
return null;
}
return $logDir;
}
/**
* Find audit log files matching date range filter.
*
* @param string $logDir Path to audit log directory.
* @return string[] Array of file paths sorted by name.
*/
private function findLogFiles(string $logDir): array
{
$pattern = $logDir . '/audit_*.jsonl';
$allFiles = glob($pattern) ?: [];
$serviceFilter = (string) $this->getArgument('--service');
$sinceDate = (string) $this->getArgument('--since');
$untilDate = (string) $this->getArgument('--until');
$filtered = [];
foreach ($allFiles as $file) {
$basename = basename($file);
// Parse service and date from filename: audit_<service>_<YYYYMMDD>.jsonl
if (!preg_match('/^audit_(.+)_(\d{8})\.jsonl$/', $basename, $matches)) {
continue;
}
$fileService = $matches[1];
$fileDate = $matches[2];
// Filter by service name from filename (efficient pre-filter).
if ($serviceFilter !== '' && $fileService !== $serviceFilter) {
continue;
}
// Filter by date range from filename (efficient pre-filter).
if ($sinceDate !== '') {
$sinceCompact = str_replace('-', '', $sinceDate);
if ($fileDate < $sinceCompact) {
continue;
}
}
if ($untilDate !== '') {
$untilCompact = str_replace('-', '', $untilDate);
if ($fileDate > $untilCompact) {
continue;
}
}
$filtered[] = $file;
}
sort($filtered);
return $filtered;
}
/**
* Load and parse JSONL entries from log files.
*
* @param string[] $files Array of file paths.
* @return array<int, array<string, mixed>> Parsed entries.
*/
private function loadEntries(array $files): array
{
$entries = [];
$lineCount = 0;
foreach ($files as $file) {
$handle = fopen($file, 'r');
if ($handle === false) {
$this->log('WARNING', 'Cannot open file: ' . $file);
continue;
}
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
$entry = json_decode($line, true);
if (!is_array($entry)) {
$lineCount++;
continue;
}
$entries[] = $entry;
$lineCount++;
}
fclose($handle);
}
$this->log('DEBUG', sprintf('Parsed %d entries from %d lines', count($entries), $lineCount));
return $entries;
}
/**
* Apply user/event/level/date filters to entries.
*
* @param array<int, array<string, mixed>> $entries Raw entries.
* @return array<int, array<string, mixed>> Filtered entries.
*/
private function filterEntries(array $entries): array
{
$userFilter = (string) $this->getArgument('--user');
$eventFilter = (string) $this->getArgument('--event');
$levelFilter = (string) $this->getArgument('--level');
$serviceFilter = (string) $this->getArgument('--service');
$sinceDate = (string) $this->getArgument('--since');
$untilDate = (string) $this->getArgument('--until');
$filtered = [];
foreach ($entries as $entry) {
// Filter by service (in case filename pre-filter was not exact).
if ($serviceFilter !== '' && ($entry['service'] ?? '') !== $serviceFilter) {
continue;
}
// Filter by user.
if ($userFilter !== '' && ($entry['user'] ?? '') !== $userFilter) {
continue;
}
// Filter by event type (matches event_type or event_subtype).
if ($eventFilter !== '') {
$eventType = $entry['event_type'] ?? '';
$eventSubtype = $entry['event_subtype'] ?? '';
if ($eventType !== $eventFilter && $eventSubtype !== $eventFilter) {
continue;
}
}
// Filter by level.
if ($levelFilter !== '' && ($entry['level'] ?? '') !== $levelFilter) {
continue;
}
// Filter by timestamp (precise, within-file filtering).
$timestamp = $entry['timestamp'] ?? '';
if ($timestamp !== '' && $sinceDate !== '') {
$entryDate = substr($timestamp, 0, 10); // YYYY-MM-DD from ISO 8601
if ($entryDate < $sinceDate) {
continue;
}
}
if ($timestamp !== '' && $untilDate !== '') {
$entryDate = substr($timestamp, 0, 10);
if ($entryDate > $untilDate) {
continue;
}
}
$filtered[] = $entry;
}
return $filtered;
}
/**
* Output entries in the requested format.
*
* @param array<int, array<string, mixed>> $entries Filtered entries.
* @return int Exit code.
*/
private function outputEntries(array $entries): int
{
$format = (string) $this->getArgument('--format', 'table');
$this->section('Audit Log Results');
$this->log('INFO', sprintf('Showing %d entries', count($entries)));
switch ($format) {
case 'json':
echo json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
break;
case 'jsonl':
foreach ($entries as $entry) {
echo json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n";
}
break;
case 'table':
default:
$this->renderTable($entries);
break;
}
return self::EXIT_SUCCESS;
}
/**
* Render entries as a formatted table.
*
* @param array<int, array<string, mixed>> $entries Entries to display.
*/
private function renderTable(array $entries): void
{
$headers = ['Time', 'Service', 'User', 'Event', 'Message'];
$rows = [];
foreach ($entries as $entry) {
$timestamp = $entry['timestamp'] ?? '';
// Shorten timestamp to YYYY-MM-DD HH:MM:SS.
if (strlen($timestamp) >= 19) {
$time = substr($timestamp, 0, 19);
$time = str_replace('T', ' ', $time);
} else {
$time = $timestamp;
}
$service = $entry['service'] ?? '';
$user = $entry['user'] ?? '';
// Build event string from event_type + event_subtype.
$eventParts = [];
if (!empty($entry['event_type'])) {
$eventParts[] = $entry['event_type'];
}
if (!empty($entry['event_subtype'])) {
$eventParts[] = $entry['event_subtype'];
}
$event = implode('/', $eventParts);
// Build message from message field or data summary.
$message = $entry['message'] ?? '';
if ($message === '' && !empty($entry['data']) && is_array($entry['data'])) {
$dataParts = [];
foreach ($entry['data'] as $key => $value) {
if (is_scalar($value)) {
$dataParts[] = "{$key}={$value}";
}
}
$message = implode(', ', array_slice($dataParts, 0, 3));
if (count($dataParts) > 3) {
$message .= '...';
}
}
// Truncate long messages.
if (strlen($message) > 60) {
$message = substr($message, 0, 57) . '...';
}
$rows[] = [$time, $service, $user, $event, $message];
}
$this->table($headers, $rows);
}
/**
* Show aggregate statistics from filtered entries.
*
* @param array<int, array<string, mixed>> $entries Filtered entries.
* @return int Exit code.
*/
private function showStats(array $entries): int
{
$this->section('Audit Log Statistics');
$total = count($entries);
if ($total === 0) {
$this->log('INFO', 'No entries match the given filters.');
return self::EXIT_SUCCESS;
}
// Aggregate counts.
$byService = [];
$byUser = [];
$byEventType = [];
$byLevel = [];
foreach ($entries as $entry) {
$service = $entry['service'] ?? 'unknown';
$user = $entry['user'] ?? 'unknown';
$eventType = $entry['event_type'] ?? 'unknown';
$level = $entry['level'] ?? '-';
$byService[$service] = ($byService[$service] ?? 0) + 1;
$byUser[$user] = ($byUser[$user] ?? 0) + 1;
$byEventType[$eventType] = ($byEventType[$eventType] ?? 0) + 1;
$byLevel[$level] = ($byLevel[$level] ?? 0) + 1;
}
arsort($byService);
arsort($byUser);
arsort($byEventType);
arsort($byLevel);
// Build summary rows.
$rows = ['Total entries' => $total];
// Top services.
$i = 0;
foreach ($byService as $name => $count) {
if ($i >= 5) {
break;
}
$rows["Service: {$name}"] = $count;
$i++;
}
// Top users.
$i = 0;
foreach ($byUser as $name => $count) {
if ($i >= 5) {
break;
}
$rows["User: {$name}"] = $count;
$i++;
}
// Event types.
foreach ($byEventType as $name => $count) {
$rows["Event: {$name}"] = $count;
}
// Levels.
foreach ($byLevel as $name => $count) {
$rows["Level: {$name}"] = $count;
}
$this->printSummaryBox($rows);
return self::EXIT_SUCCESS;
}
}
$app = new AuditQueryCli();
exit($app->execute());
+64 -52
View File
@@ -1,68 +1,80 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/badge_update.php * PATH: /cli/badge_update.php
* BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files * BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files
*
* Usage:
* php badge_update.php --path /repo --version 04.01.00
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; class BadgeUpdateCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Update VERSION badges in all markdown files');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Version string XX.YY.ZZ', '');
}
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
if (empty($version)) {
$this->log('ERROR', 'Usage: badge_update.php --path . --version XX.YY.ZZ');
return 1;
}
$root = realpath($path) ?: $path;
$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/';
$replacement = "[VERSION: {$version}]";
$updated = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
$filePath = $file->getPathname();
if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) {
continue;
}
if (!preg_match('/\.md$/i', $filePath)) {
continue;
}
$content = file_get_contents($filePath);
if (preg_match($pattern, $content)) {
$newContent = preg_replace($pattern, $replacement, $content);
if ($newContent !== $content) {
if (!$this->dryRun) {
file_put_contents($filePath, $newContent);
}
$relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
$this->log('INFO', "Updated: {$relative}");
$updated++;
}
}
}
$this->success("Updated {$updated} file(s) to {$replacement}");
return 0;
}
} }
if ($version === null) { $app = new BadgeUpdateCli();
fwrite(STDERR, "Usage: badge_update.php --path . --version XX.YY.ZZ\n"); exit($app->execute());
exit(1);
}
$root = realpath($path) ?: $path;
$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/';
$replacement = "[VERSION: {$version}]";
$updated = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
$filePath = $file->getPathname();
// Skip .git and vendor directories
if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) {
continue;
}
// Only process markdown files
if (!preg_match('/\.md$/i', $filePath)) {
continue;
}
$content = file_get_contents($filePath);
if (preg_match($pattern, $content)) {
$newContent = preg_replace($pattern, $replacement, $content);
if ($newContent !== $content) {
file_put_contents($filePath, $newContent);
$relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
echo "Updated: {$relative}\n";
$updated++;
}
}
}
echo "Updated {$updated} file(s) to {$replacement}\n";
exit(0);
+125 -115
View File
@@ -1,138 +1,148 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/branch_rename.php * PATH: /cli/branch_rename.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old) * BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
*
* Usage:
* php branch_rename.php --from dev --to rc --token TOKEN --api-base URL [--pr 42]
* php branch_rename.php --from dev --to rc --token TOKEN --api-base URL --pr 42 --dry-run
*/ */
declare(strict_types=1); declare(strict_types=1);
$from = ''; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$to = '';
$token = '';
$apiBase = '';
$prNum = '';
$dryRun = false;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1];
if ($arg === '--to' && isset($argv[$i + 1])) $to = $argv[$i + 1];
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
if ($arg === '--pr' && isset($argv[$i + 1])) $prNum = $argv[$i + 1];
if ($arg === '--dry-run') $dryRun = true;
}
if (empty($from) || empty($to) || empty($token) || empty($apiBase)) { class BranchRenameCli extends CliFramework
fwrite(STDERR, "Usage: branch_rename.php --from BRANCH --to BRANCH --token TOKEN --api-base URL [--pr NUM] [--dry-run]\n");
exit(1);
}
if ($from === $to) {
echo "Source and target are the same ({$from}) — nothing to do\n";
exit(0);
}
$headers = [
"Authorization: token {$token}",
'Content-Type: application/json',
'Accept: application/json',
];
/**
* Make an API request.
*/
function apiRequest(string $method, string $url, array $headers, ?array $body = null): array
{ {
$ch = curl_init(); protected function configure(): void
curl_setopt_array($ch, [ {
CURLOPT_URL => $url, $this->setDescription('Rename a git branch via Gitea API (create new, update PR, delete old)');
CURLOPT_CUSTOMREQUEST => $method, $this->addArgument('--from', 'Source branch name', '');
CURLOPT_RETURNTRANSFER => true, $this->addArgument('--to', 'Target branch name', '');
CURLOPT_HTTPHEADER => $headers, $this->addArgument('--token', 'API token', '');
CURLOPT_TIMEOUT => 30, $this->addArgument('--api-base', 'API base URL', '');
]); $this->addArgument('--pr', 'PR number to update head branch', '');
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
} }
$response = curl_exec($ch); protected function run(): int
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); {
curl_close($ch); $from = $this->getArgument('--from');
$to = $this->getArgument('--to');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$prNum = $this->getArgument('--pr');
return [ if (empty($from) || empty($to) || empty($token) || empty($apiBase)) {
'code' => $httpCode, $this->log('ERROR', 'Usage: branch_rename.php --from BRANCH --to BRANCH --token TOKEN --api-base URL [--pr NUM] [--dry-run]');
'body' => json_decode($response ?: '{}', true) ?: [], return 1;
];
}
// Step 1: Verify source branch exists
echo "Checking source branch: {$from}\n";
$check = apiRequest('GET', "{$apiBase}/branches/{$from}", $headers);
if ($check['code'] !== 200) {
fwrite(STDERR, "Source branch '{$from}' not found (HTTP {$check['code']})\n");
exit(1);
}
// Step 2: Delete target branch if it already exists
$targetCheck = apiRequest('GET', "{$apiBase}/branches/{$to}", $headers);
if ($targetCheck['code'] === 200) {
echo "Target branch '{$to}' already exists — deleting\n";
if (!$dryRun) {
apiRequest('DELETE', "{$apiBase}/branches/{$to}", $headers);
}
}
// Step 3: Create new branch from source
echo "Creating branch: {$to} (from {$from})\n";
if (!$dryRun) {
$create = apiRequest('POST', "{$apiBase}/branches", $headers, [
'new_branch_name' => $to,
'old_branch_name' => $from,
]);
if ($create['code'] < 200 || $create['code'] >= 300) {
fwrite(STDERR, "Failed to create branch '{$to}': HTTP {$create['code']}\n");
fwrite(STDERR, json_encode($create['body']) . "\n");
exit(1);
}
}
// Step 4: Update PR head branch if PR number provided
if (!empty($prNum)) {
echo "Updating PR #{$prNum} head branch: {$from} -> {$to}\n";
if (!$dryRun) {
$update = apiRequest('PATCH', "{$apiBase}/pulls/{$prNum}", $headers, [
'head' => $to,
]);
if ($update['code'] < 200 || $update['code'] >= 300) {
fwrite(STDERR, "Warning: Could not update PR head branch (HTTP {$update['code']})\n");
// Non-fatal — the PR may need manual update
} }
if ($from === $to) {
echo "Source and target are the same ({$from}) — nothing to do\n";
return 0;
}
$headers = [
"Authorization: token {$token}",
'Content-Type: application/json',
'Accept: application/json',
];
// Step 1: Verify source branch exists
echo "Checking source branch: {$from}\n";
$check = $this->apiRequest('GET', "{$apiBase}/branches/{$from}", $headers);
if ($check['code'] !== 200) {
$this->log('ERROR', "Source branch '{$from}' not found (HTTP {$check['code']})");
return 1;
}
// Step 2: Delete target branch if it already exists
$targetCheck = $this->apiRequest('GET', "{$apiBase}/branches/{$to}", $headers);
if ($targetCheck['code'] === 200) {
echo "Target branch '{$to}' already exists — deleting\n";
if (!$this->dryRun) {
$this->apiRequest('DELETE', "{$apiBase}/branches/{$to}", $headers);
}
}
// Step 3: Create new branch from source
echo "Creating branch: {$to} (from {$from})\n";
if (!$this->dryRun) {
$create = $this->apiRequest('POST', "{$apiBase}/branches", $headers, [
'new_branch_name' => $to,
'old_branch_name' => $from,
]);
if ($create['code'] < 200 || $create['code'] >= 300) {
$this->log('ERROR', "Failed to create branch '{$to}': HTTP {$create['code']}");
$this->log('ERROR', json_encode($create['body']));
return 1;
}
}
// Step 4: Update PR head branch if PR number provided
if (!empty($prNum)) {
echo "Updating PR #{$prNum} head branch: {$from} -> {$to}\n";
if (!$this->dryRun) {
$update = $this->apiRequest('PATCH', "{$apiBase}/pulls/{$prNum}", $headers, [
'head' => $to,
]);
if ($update['code'] < 200 || $update['code'] >= 300) {
$this->log('ERROR', "Warning: Could not update PR head branch (HTTP {$update['code']})");
// Non-fatal — the PR may need manual update
}
}
}
// Step 5: Delete old source branch
echo "Deleting old branch: {$from}\n";
if (!$this->dryRun) {
$delete = $this->apiRequest('DELETE', "{$apiBase}/branches/{$from}", $headers);
if ($delete['code'] !== 204 && $delete['code'] !== 200) {
$this->log('ERROR', "Warning: Could not delete old branch '{$from}' (HTTP {$delete['code']})");
// Non-fatal — branch protection may prevent deletion
}
}
echo "Renamed: {$from} -> {$to}\n";
return 0;
}
/**
* Make an API request.
*/
private function apiRequest(string $method, string $url, array $headers, ?array $body = null): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'code' => $httpCode,
'body' => json_decode($response ?: '{}', true) ?: [],
];
} }
} }
// Step 5: Delete old source branch $app = new BranchRenameCli();
echo "Deleting old branch: {$from}\n"; exit($app->execute());
if (!$dryRun) {
$delete = apiRequest('DELETE', "{$apiBase}/branches/{$from}", $headers);
if ($delete['code'] !== 204 && $delete['code'] !== 200) {
fwrite(STDERR, "Warning: Could not delete old branch '{$from}' (HTTP {$delete['code']})\n");
// Non-fatal — branch protection may prevent deletion
}
}
echo "Renamed: {$from} -> {$to}\n";
exit(0);
+92 -170
View File
@@ -8,114 +8,129 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/bulk_workflow_push.php * PATH: /cli/bulk_workflow_push.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API * BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
*/ */
declare(strict_types=1); declare(strict_types=1);
final class BulkWorkflowPush require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
{
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = '';
private string $org = '';
private string $workflowFile = '';
private string $destPath = '';
private string $branch = 'main';
private bool $dryRun = false;
use MokoCli\CliFramework;
class BulkWorkflowPushCli extends CliFramework
{
private int $updated = 0; private int $updated = 0;
private int $created = 0; private int $created = 0;
private int $skipped = 0; private int $skipped = 0;
private int $errors = 0; private int $errors = 0;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Push a workflow file to all governed repos via the Gitea Contents API');
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--org', 'Target organization', '');
$this->addArgument('--file', 'Local workflow file to push', '');
$this->addArgument('--dest', 'Destination path in repos (default: .mokogitea/workflows/<filename>)', '');
$this->addArgument('--branch', 'Target branch (default: main)', 'main');
}
if ($this->token === '') { protected function run(): int
$this->log('ERROR: --token is required.'); {
$this->printUsage(); $giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$token = $this->getArgument('--token');
$org = $this->getArgument('--org');
$workflowFile = $this->getArgument('--file');
$destPath = $this->getArgument('--dest');
$branch = $this->getArgument('--branch');
if ($token === '') {
$this->log('ERROR', '--token is required.');
return 1; return 1;
} }
if ($this->workflowFile === '') { if ($workflowFile === '') {
$this->log('ERROR: --file is required.'); $this->log('ERROR', '--file is required.');
$this->printUsage();
return 1; return 1;
} }
if (!file_exists($this->workflowFile)) { if (!file_exists($workflowFile)) {
$this->log("ERROR: File not found: {$this->workflowFile}"); $this->log('ERROR', "File not found: {$workflowFile}");
return 1; return 1;
} }
if ($this->org === '') { if ($org === '') {
$this->log('ERROR: --org is required.'); $this->log('ERROR', '--org is required.');
$this->printUsage();
return 1; return 1;
} }
if ($this->destPath === '') { if ($destPath === '') {
$this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile); $destPath = '.mokogitea/workflows/' . basename($workflowFile);
} }
$localContent = file_get_contents($this->workflowFile); $localContent = file_get_contents($workflowFile);
if ($localContent === false) { if ($localContent === false) {
$this->log("ERROR: Could not read file: {$this->workflowFile}"); $this->log('ERROR', "Could not read file: {$workflowFile}");
return 1; return 1;
} }
$this->log("Pushing: {$this->workflowFile}"); $this->log('INFO', "Pushing: {$workflowFile}");
$this->log(" -> {$this->destPath} (branch: {$this->branch})"); $this->log('INFO', " -> {$destPath} (branch: {$branch})");
$this->log(" -> Org: {$this->org} @ {$this->giteaUrl}"); $this->log('INFO', " -> Org: {$org} @ {$giteaUrl}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('[DRY RUN] No changes will be made.'); $this->log('INFO', '[DRY RUN] No changes will be made.');
} }
$this->log(''); echo "\n";
$repos = $this->fetchOrgRepos(); $repos = $this->fetchOrgRepos($giteaUrl, $token, $org);
if ($repos === null) { if ($repos === null) {
return 1; return 1;
} }
$this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\"."); $this->log('INFO', "Found " . count($repos) . " repo(s) in \"{$org}\".");
$this->log(''); echo "\n";
$this->log(sprintf('%-45s | %s', 'Repo', 'Status')); fprintf(STDERR, "%-45s | %s\n", 'Repo', 'Status');
$this->log(str_repeat('-', 70)); fprintf(STDERR, "%s\n", str_repeat('-', 70));
$encodedContent = base64_encode($localContent); $encodedContent = base64_encode($localContent);
foreach ($repos as $repo) { foreach ($repos as $repo) {
$this->pushToRepo($repo, $encodedContent, $localContent); $this->pushToRepo($giteaUrl, $token, $repo, $encodedContent, $localContent, $destPath, $branch);
} }
$this->log(''); echo "\n";
$this->log("Done: {$this->created} created, {$this->updated} updated, " $this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, "
. "{$this->skipped} skipped, {$this->errors} error(s)."); . "{$this->skipped} skipped, {$this->errors} error(s).");
return $this->errors > 0 ? 1 : 0; return $this->errors > 0 ? 1 : 0;
} }
private function pushToRepo( private function pushToRepo(
string $giteaUrl,
string $token,
string $repoFullName, string $repoFullName,
string $encodedContent, string $encodedContent,
string $localContent string $localContent,
string $destPath,
string $branch
): void { ): void {
[$owner, $repoName] = explode('/', $repoFullName, 2); [$owner, $repoName] = explode('/', $repoFullName, 2);
$existing = $this->apiRequest( $existing = $this->apiRequest(
$giteaUrl,
$token,
'GET', 'GET',
"/api/v1/repos/{$owner}/{$repoName}/contents/" "/api/v1/repos/{$owner}/{$repoName}/contents/"
. "{$this->destPath}?ref={$this->branch}" . "{$destPath}?ref={$branch}"
); );
if ($existing['code'] === 200) { if ($existing['code'] === 200) {
@@ -124,21 +139,13 @@ final class BulkWorkflowPush
$remoteContent = base64_decode($data['content'] ?? ''); $remoteContent = base64_decode($data['content'] ?? '');
if ($remoteContent === $localContent) { if ($remoteContent === $localContent) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'IDENTICAL (skipped)');
'%-45s | %s',
$repoFullName,
'IDENTICAL (skipped)'
));
$this->skipped++; $this->skipped++;
return; return;
} }
if ($this->dryRun) { if ($this->dryRun) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD UPDATE');
'%-45s | %s',
$repoFullName,
'WOULD UPDATE'
));
$this->updated++; $this->updated++;
return; return;
} }
@@ -146,100 +153,82 @@ final class BulkWorkflowPush
$payload = json_encode([ $payload = json_encode([
'content' => $encodedContent, 'content' => $encodedContent,
'sha' => $remoteSha, 'sha' => $remoteSha,
'message' => "chore: sync {$this->destPath} " 'message' => "chore: sync {$destPath} "
. "from moko-platform [skip ci]", . "from mokocli [skip ci]",
'branch' => $this->branch, 'branch' => $branch,
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest(
$giteaUrl,
$token,
'PUT', 'PUT',
"/api/v1/repos/{$owner}/{$repoName}/contents/" "/api/v1/repos/{$owner}/{$repoName}/contents/"
. $this->destPath, . $destPath,
$payload $payload
); );
if ($response['code'] === 200) { if ($response['code'] === 200) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'UPDATED');
'%-45s | %s',
$repoFullName,
'UPDATED'
));
$this->updated++; $this->updated++;
} else { } else {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$response['code']})"
));
$this->errors++; $this->errors++;
} }
} elseif ($existing['code'] === 404) { } elseif ($existing['code'] === 404) {
if ($this->dryRun) { if ($this->dryRun) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD CREATE');
'%-45s | %s',
$repoFullName,
'WOULD CREATE'
));
$this->created++; $this->created++;
return; return;
} }
$payload = json_encode([ $payload = json_encode([
'content' => $encodedContent, 'content' => $encodedContent,
'message' => "chore: add {$this->destPath} " 'message' => "chore: add {$destPath} "
. "from moko-platform [skip ci]", . "from mokocli [skip ci]",
'branch' => $this->branch, 'branch' => $branch,
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest(
$giteaUrl,
$token,
'POST', 'POST',
"/api/v1/repos/{$owner}/{$repoName}/contents/" "/api/v1/repos/{$owner}/{$repoName}/contents/"
. $this->destPath, . $destPath,
$payload $payload
); );
if ($response['code'] === 201) { if ($response['code'] === 201) {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'CREATED');
'%-45s | %s',
$repoFullName,
'CREATED'
));
$this->created++; $this->created++;
} else { } else {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$response['code']})"
));
$this->errors++; $this->errors++;
} }
} else { } else {
$this->log(sprintf( fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$existing['code']})");
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$existing['code']})"
));
$this->errors++; $this->errors++;
} }
} }
private function fetchOrgRepos(): ?array private function fetchOrgRepos(string $giteaUrl, string $token, string $org): ?array
{ {
$this->log("Fetching repos from org: {$this->org}"); $this->log('INFO', "Fetching repos from org: {$org}");
$page = 1; $page = 1;
$repos = []; $repos = [];
while (true) { while (true) {
$response = $this->apiRequest( $response = $this->apiRequest(
$giteaUrl,
$token,
'GET', 'GET',
"/api/v1/orgs/{$this->org}/repos?" "/api/v1/orgs/{$org}/repos?"
. "limit=50&page={$page}" . "limit=50&page={$page}"
); );
if ($response['code'] < 200 || $response['code'] >= 300) { if ($response['code'] < 200 || $response['code'] >= 300) {
if ($page === 1) { if ($page === 1) {
$this->log("ERROR: Could not fetch repos " $this->log('ERROR', "Could not fetch repos "
. "(HTTP {$response['code']})."); . "(HTTP {$response['code']}).");
return null; return null;
} }
@@ -271,76 +260,14 @@ final class BulkWorkflowPush
return $repos; return $repos;
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break;
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--file':
$this->workflowFile = $args[++$i] ?? '';
break;
case '--dest':
$this->destPath = $args[++$i] ?? '';
break;
case '--branch':
$this->branch = $args[++$i] ?? 'main';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log(
'Usage: bulk_workflow_push.php '
. '--token <token> --file <path> --org <org> [options]'
);
$this->log('');
$this->log(
'Push a workflow file from moko-platform '
. 'to all governed repos.'
);
$this->log('');
$this->log('Options:');
$this->log(' --gitea-url <url> Gitea URL '
. '(default: https://git.mokoconsulting.tech)');
$this->log(' --token <token> Gitea API token');
$this->log(' --org <org> Target organization');
$this->log(' --file <path> Local workflow file to push');
$this->log(' --dest <path> Destination path in repos '
. '(default: .mokogitea/workflows/<filename>)');
$this->log(' --branch <branch> Target branch (default: main)');
$this->log(' --dry-run Show what would be done');
$this->log(' --help, -h Show this help');
}
private function apiRequest( private function apiRequest(
string $giteaUrl,
string $token,
string $method, string $method,
string $endpoint, string $endpoint,
?string $body = null ?string $body = null
): array { ): array {
$url = $this->giteaUrl . $endpoint; $url = $giteaUrl . $endpoint;
$ch = curl_init(); $ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_URL, $url);
@@ -349,7 +276,7 @@ final class BulkWorkflowPush
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
'Accept: application/json', 'Accept: application/json',
"Authorization: token {$this->token}", "Authorization: token {$token}",
]); ]);
if ($body !== null) { if ($body !== null) {
@@ -376,12 +303,7 @@ final class BulkWorkflowPush
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new BulkWorkflowPush(); $app = new BulkWorkflowPushCli();
exit($app->run()); exit($app->execute());
+178 -252
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,313 +8,238 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/bulk_workflow_trigger.php * PATH: /cli/bulk_workflow_trigger.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Trigger a workflow across multiple repos at once * BRIEF: Trigger a workflow across multiple repos at once
*/ */
declare(strict_types=1); declare(strict_types=1);
final class BulkWorkflowTrigger require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class BulkWorkflowTriggerCli extends CliFramework
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
private string $reposFile = ''; private string $reposFile = '';
private string $org = ''; private string $org = '';
private string $workflow = ''; private string $workflow = '';
private string $ref = 'main'; private string $ref = 'main';
private string $inputs = ''; private string $inputs = '';
private bool $dryRun = false;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Trigger a workflow across multiple repos at once');
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--repos', 'File with newline-separated owner/repo list', '');
$this->addArgument('--org', 'Trigger on all repos in an org', '');
$this->addArgument('--workflow', 'Workflow file (e.g., "sync-servers.yml")', '');
$this->addArgument('--ref', 'Branch ref (default: "main")', 'main');
$this->addArgument('--inputs', 'Workflow inputs as JSON string', '');
}
if ($this->token === '') protected function run(): int
{ {
$this->log('ERROR: --token is required.'); $this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$this->printUsage(); $this->token = $this->getArgument('--token');
return 1; $this->reposFile = $this->getArgument('--repos');
} $this->org = $this->getArgument('--org');
$this->workflow = $this->getArgument('--workflow');
$this->ref = $this->getArgument('--ref');
$this->inputs = $this->getArgument('--inputs');
if ($this->workflow === '') if ($this->token === '') {
{ $this->log('ERROR', '--token is required.');
$this->log('ERROR: --workflow is required.'); return 1;
$this->printUsage(); }
return 1;
}
if ($this->reposFile === '' && $this->org === '') if ($this->workflow === '') {
{ $this->log('ERROR', '--workflow is required.');
$this->log('ERROR: Either --repos <file> or --org <org> is required.'); return 1;
$this->printUsage(); }
return 1;
}
// Build repo list if ($this->reposFile === '' && $this->org === '') {
$repos = $this->buildRepoList(); $this->log('ERROR', 'Either --repos <file> or --org <org> is required.');
return 1;
}
if ($repos === null || count($repos) === 0) // Build repo list
{ $repos = $this->buildRepoList();
$this->log('ERROR: No repos found to process.');
return 1;
}
$this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s)."); if ($repos === null || count($repos) === 0) {
$this->log("Gitea URL: {$this->giteaUrl}"); $this->log('ERROR', 'No repos found to process.');
return 1;
}
if ($this->dryRun) $this->log('INFO', "Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s).");
{ $this->log('INFO', "Gitea URL: {$this->giteaUrl}");
$this->log('[DRY RUN] No requests will be sent.');
}
$this->log(''); if ($this->dryRun) {
$this->log('INFO', '[DRY RUN] No requests will be sent.');
}
// Parse inputs $this->log('INFO', '');
$inputsDecoded = null;
if ($this->inputs !== '') // Parse inputs
{ $inputsDecoded = null;
$inputsDecoded = json_decode($this->inputs, true);
if (!is_array($inputsDecoded)) if ($this->inputs !== '') {
{ $inputsDecoded = json_decode($this->inputs, true);
$this->log('ERROR: --inputs must be valid JSON.');
return 1;
}
}
// Print header if (!is_array($inputsDecoded)) {
$this->log(sprintf('%-40s | %s', 'Repo', 'Status')); $this->log('ERROR', '--inputs must be valid JSON.');
$this->log(str_repeat('-', 60)); return 1;
}
}
$failCount = 0; // Print header
$this->log('INFO', sprintf('%-40s | %s', 'Repo', 'Status'));
$this->log('INFO', str_repeat('-', 60));
foreach ($repos as $repo) $failCount = 0;
{
$repo = trim($repo);
if ($repo === '' || strpos($repo, '/') === false) foreach ($repos as $repo) {
{ $repo = trim($repo);
continue;
}
[$owner, $repoName] = explode('/', $repo, 2); if ($repo === '' || strpos($repo, '/') === false) {
continue;
}
if ($this->dryRun) [$owner, $repoName] = explode('/', $repo, 2);
{
$this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
continue;
}
$payload = ['ref' => $this->ref]; if ($this->dryRun) {
$this->log('INFO', sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
continue;
}
if ($inputsDecoded !== null) $payload = ['ref' => $this->ref];
{
$payload['inputs'] = $inputsDecoded;
}
$response = $this->apiRequest( if ($inputsDecoded !== null) {
'POST', $payload['inputs'] = $inputsDecoded;
"/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches", }
json_encode($payload)
);
if ($response['code'] >= 200 && $response['code'] < 300) $response = $this->apiRequest(
{ 'POST',
$status = 'TRIGGERED'; "/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches",
} json_encode($payload)
elseif ($response['code'] === 404) );
{
$status = 'FAILED (not found)';
$failCount++;
}
elseif ($response['code'] === 422)
{
$status = 'SKIPPED (unprocessable)';
}
else
{
$status = "FAILED (HTTP {$response['code']})";
$failCount++;
}
$this->log(sprintf('%-40s | %s', $repo, $status)); if ($response['code'] >= 200 && $response['code'] < 300) {
} $status = 'TRIGGERED';
} elseif ($response['code'] === 404) {
$status = 'FAILED (not found)';
$failCount++;
} elseif ($response['code'] === 422) {
$status = 'SKIPPED (unprocessable)';
} else {
$status = "FAILED (HTTP {$response['code']})";
$failCount++;
}
$this->log(''); $this->log('INFO', sprintf('%-40s | %s', $repo, $status));
$this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.')); }
return $failCount > 0 ? 1 : 0; $this->log('INFO', '');
} $this->log('INFO', 'Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
private function parseArgs(): void return $failCount > 0 ? 1 : 0;
{ }
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) private function buildRepoList(): ?array
{ {
switch ($args[$i]) if ($this->reposFile !== '') {
{ if (!file_exists($this->reposFile)) {
case '--gitea-url': $this->log('ERROR', "Repos file not found: {$this->reposFile}");
$this->giteaUrl = rtrim($args[++$i] ?? '', '/'); return null;
break; }
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--repos':
$this->reposFile = $args[++$i] ?? '';
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--workflow':
$this->workflow = $args[++$i] ?? '';
break;
case '--ref':
$this->ref = $args[++$i] ?? 'main';
break;
case '--inputs':
$this->inputs = $args[++$i] ?? '';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
}
private function printUsage(): void $content = file_get_contents($this->reposFile);
{ $lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool {
$this->log('Usage: bulk_workflow_trigger.php --token <token> --workflow <file> [options]'); return $line !== '' && $line[0] !== '#';
$this->log(''); });
$this->log('Options:');
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
$this->log(' --token <token> Gitea API token');
$this->log(' --repos <file> File with newline-separated owner/repo list');
$this->log(' --org <org> Trigger on all repos in an org');
$this->log(' --workflow <filename> Workflow file (e.g., "sync-servers.yml")');
$this->log(' --ref <branch> Branch ref (default: "main")');
$this->log(' --inputs <json> Workflow inputs as JSON string');
$this->log(' --dry-run Show what would be done without triggering');
$this->log(' --help, -h Show this help');
}
private function buildRepoList(): ?array return array_values($lines);
{ }
if ($this->reposFile !== '')
{
if (!file_exists($this->reposFile))
{
$this->log("ERROR: Repos file not found: {$this->reposFile}");
return null;
}
$content = file_get_contents($this->reposFile); // Fetch all repos from org
$lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool { $this->log('INFO', "Fetching repos from org: {$this->org}");
return $line !== '' && $line[0] !== '#';
});
return array_values($lines); $page = 1;
} $repos = [];
// Fetch all repos from org while (true) {
$this->log("Fetching repos from org: {$this->org}"); $response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
$page = 1; if ($response['code'] < 200 || $response['code'] >= 300) {
$repos = []; if ($page === 1) {
$this->log('ERROR', "Could not fetch repos for org (HTTP {$response['code']}).");
return null;
}
while (true) break;
{ }
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
if ($response['code'] < 200 || $response['code'] >= 300) $data = json_decode($response['body'], true);
{
if ($page === 1)
{
$this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']}).");
return null;
}
break; if (!is_array($data) || count($data) === 0) {
} break;
}
$data = json_decode($response['body'], true); foreach ($data as $repo) {
$fullName = $repo['full_name'] ?? '';
if (!is_array($data) || count($data) === 0) if ($fullName !== '') {
{ $repos[] = $fullName;
break; }
} }
foreach ($data as $repo) $page++;
{ }
$fullName = $repo['full_name'] ?? '';
if ($fullName !== '') $this->log('INFO', 'Found ' . count($repos) . " repo(s) in org \"{$this->org}\".");
{
$repos[] = $fullName;
}
}
$page++; return $repos;
} }
$this->log('Found ' . count($repos) . " repo(s) in org \"{$this->org}\"."); private function apiRequest(string $method, string $endpoint, ?string $body = null): array
{
$url = $this->giteaUrl . $endpoint;
return $repos; $ch = curl_init();
} curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$this->token}",
]);
private function apiRequest(string $method, string $endpoint, ?string $body = null): array if ($body !== null) {
{ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
$url = $this->giteaUrl . $endpoint; }
$ch = curl_init(); $responseBody = curl_exec($ch);
curl_setopt($ch, CURLOPT_URL, $url); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$this->token}",
]);
if ($body !== null) if (curl_errno($ch)) {
{ $error = curl_error($ch);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_close($ch);
}
$responseBody = curl_exec($ch); return ['code' => 0, 'body' => "cURL error: {$error}"];
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); }
if (curl_errno($ch)) curl_close($ch);
{
$error = curl_error($ch);
curl_close($ch);
return ['code' => 0, 'body' => "cURL error: {$error}"]; return ['code' => $httpCode, 'body' => $responseBody];
} }
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new BulkWorkflowTrigger(); $app = new BulkWorkflowTriggerCli();
exit($app->run()); exit($app->execute());
+77 -66
View File
@@ -1,82 +1,93 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/changelog_promote.php * PATH: /cli/changelog_promote.php
* BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry * BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry
*
* Usage:
* php changelog_promote.php --path /repo --version 04.01.00
* php changelog_promote.php --path /repo --version 04.01.00 --date 2026-05-21
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$date = date('Y-m-d');
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; class ChangelogPromoteCli extends CliFramework
if ($arg === '--date' && isset($argv[$i + 1])) $date = $argv[$i + 1]; {
protected function configure(): void
{
$this->setDescription('Promote [Unreleased] CHANGELOG section to a versioned entry');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Version string XX.YY.ZZ', '');
$this->addArgument('--date', 'Release date YYYY-MM-DD', date('Y-m-d'));
}
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$date = $this->getArgument('--date');
if (empty($version)) {
$this->log('ERROR', 'Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]');
return 1;
}
$changelog = realpath($path) . '/CHANGELOG.md';
if (!file_exists($changelog)) {
$this->log('ERROR', "No CHANGELOG.md found at {$path}");
return 1;
}
$content = file_get_contents($changelog);
if (!preg_match('/## \[?Unreleased\]?/i', $content)) {
$this->log('ERROR', 'No [Unreleased] section found in CHANGELOG.md');
return 1;
}
// Replace [Unreleased] with versioned entry
$content = preg_replace(
'/## \[Unreleased\]/i',
"## [{$version}] --- {$date}",
$content,
1
);
$content = preg_replace(
'/## Unreleased/i',
"## [{$version}] --- {$date}",
$content,
1
);
// Insert new [Unreleased] section after the first heading line
$lines = explode("\n", $content);
$inserted = false;
$result = [];
foreach ($lines as $line) {
$result[] = $line;
if (!$inserted && preg_match('/^# /', $line)) {
$result[] = '';
$result[] = '## [Unreleased]';
$result[] = '';
$inserted = true;
}
}
$content = implode("\n", $result);
file_put_contents($changelog, $content);
$this->success("CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}");
return 0;
}
} }
if ($version === null) { $app = new ChangelogPromoteCli();
fwrite(STDERR, "Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]\n"); exit($app->execute());
exit(1);
}
$changelog = realpath($path) . '/CHANGELOG.md';
if (!file_exists($changelog)) {
fwrite(STDERR, "No CHANGELOG.md found at {$path}\n");
exit(1);
}
$content = file_get_contents($changelog);
// Check if [Unreleased] section exists
if (!preg_match('/## \[?Unreleased\]?/i', $content)) {
fwrite(STDERR, "No [Unreleased] section found in CHANGELOG.md\n");
exit(1);
}
// Replace [Unreleased] with versioned entry
$content = preg_replace(
'/## \[Unreleased\]/i',
"## [{$version}] --- {$date}",
$content,
1
);
$content = preg_replace(
'/## Unreleased/i',
"## [{$version}] --- {$date}",
$content,
1
);
// Insert new [Unreleased] section after the first heading line (# Changelog)
$lines = explode("\n", $content);
$inserted = false;
$result = [];
foreach ($lines as $line) {
$result[] = $line;
if (!$inserted && preg_match('/^# /', $line)) {
$result[] = '';
$result[] = '## [Unreleased]';
$result[] = '';
$inserted = true;
}
}
$content = implode("\n", $result);
file_put_contents($changelog, $content);
echo "CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}\n";
exit(0);
+107 -106
View File
@@ -1,134 +1,135 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/changelog_prune.php * PATH: /cli/changelog_prune.php
* BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases * BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases
*
* Usage:
* php changelog_prune.php --path /repo --keep 5
* php changelog_prune.php --path /repo --keep 3 --dry-run
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$keep = 5;
$dryRun = false;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--keep' && isset($argv[$i + 1])) $keep = (int)$argv[$i + 1]; class ChangelogPruneCli extends CliFramework
if ($arg === '--dry-run') $dryRun = true; {
if ($arg === '--help') { protected function configure(): void
echo "changelog_prune — Keep [Unreleased] + last N versioned entries\n\n"; {
echo "Usage: php changelog_prune.php --path . --keep 5 [--dry-run]\n\n"; $this->setDescription('Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases');
echo "Options:\n"; $this->addArgument('--path', 'Repository path', '.');
echo " --path Repository path (default: .)\n"; $this->addArgument('--keep', 'Number of versioned releases to keep', '5');
echo " --keep Number of versioned releases to keep (default: 5)\n";
echo " --dry-run Preview without writing\n";
exit(0);
} }
}
$changelog = realpath($path) . '/CHANGELOG.md'; protected function run(): int
if (!file_exists($changelog)) { {
fwrite(STDERR, "No CHANGELOG.md found at {$path}\n"); $path = $this->getArgument('--path');
exit(1); $keep = (int) $this->getArgument('--keep');
}
$content = file_get_contents($changelog); $changelog = realpath($path) . '/CHANGELOG.md';
$lines = explode("\n", $content); if (!file_exists($changelog)) {
$this->log('ERROR', "No CHANGELOG.md found at {$path}");
return 1;
}
// Split into sections by ## headings $content = file_get_contents($changelog);
$sections = []; $lines = explode("\n", $content);
$current = [];
$currentHeading = null;
foreach ($lines as $line) { // Split into sections by ## headings
if (preg_match('/^## /', $line)) { $sections = [];
$current = [];
$currentHeading = null;
foreach ($lines as $line) {
if (preg_match('/^## /', $line)) {
if ($currentHeading !== null) {
$sections[] = ['heading' => $currentHeading, 'lines' => $current];
}
$currentHeading = $line;
$current = [$line];
} else {
$current[] = $line;
}
}
if ($currentHeading !== null) { if ($currentHeading !== null) {
$sections[] = ['heading' => $currentHeading, 'lines' => $current]; $sections[] = ['heading' => $currentHeading, 'lines' => $current];
} }
$currentHeading = $line;
$current = [$line];
} else {
$current[] = $line;
}
}
if ($currentHeading !== null) {
$sections[] = ['heading' => $currentHeading, 'lines' => $current];
}
// Find the header (everything before the first ## section) // Find the header (everything before the first ## section)
$header = []; $header = [];
$contentLines = explode("\n", $content); $contentLines = explode("\n", $content);
foreach ($contentLines as $line) { foreach ($contentLines as $line) {
if (preg_match('/^## /', $line)) { if (preg_match('/^## /', $line)) {
break; break;
} }
$header[] = $line; $header[] = $line;
} }
// Separate [Unreleased] from versioned sections // Separate [Unreleased] from versioned sections
$unreleased = null; $unreleased = null;
$versioned = []; $versioned = [];
foreach ($sections as $section) { foreach ($sections as $section) {
if (preg_match('/\[Unreleased\]/i', $section['heading'])) { if (preg_match('/\[Unreleased\]/i', $section['heading'])) {
$unreleased = $section; $unreleased = $section;
} else { } else {
$versioned[] = $section; $versioned[] = $section;
}
}
$totalVersioned = count($versioned);
$pruned = $totalVersioned - $keep;
if ($pruned <= 0) {
echo "CHANGELOG has {$totalVersioned} versioned entries — nothing to prune (keeping {$keep})\n";
return 0;
}
// Keep only the first N versioned sections
$keptVersioned = array_slice($versioned, 0, $keep);
$droppedVersioned = array_slice($versioned, $keep);
// Report
echo "CHANGELOG: {$totalVersioned} versioned entries found\n";
echo " Keeping: {$keep} most recent\n";
echo " Pruning: {$pruned} old entries\n";
foreach ($droppedVersioned as $section) {
$heading = trim($section['heading']);
echo " - {$heading}\n";
}
if ($this->dryRun) {
echo "\n(dry-run) No changes written\n";
return 0;
}
// Rebuild the file
$output = implode("\n", $header);
if ($unreleased !== null) {
$output .= implode("\n", $unreleased['lines']) . "\n";
}
foreach ($keptVersioned as $section) {
$output .= implode("\n", $section['lines']) . "\n";
}
// Clean up excessive blank lines at end
$output = rtrim($output) . "\n";
file_put_contents($changelog, $output);
echo "\nCHANGELOG pruned: removed {$pruned} old entries\n";
return 0;
} }
} }
$totalVersioned = count($versioned); $app = new ChangelogPruneCli();
$pruned = $totalVersioned - $keep; exit($app->execute());
if ($pruned <= 0) {
echo "CHANGELOG has {$totalVersioned} versioned entries — nothing to prune (keeping {$keep})\n";
exit(0);
}
// Keep only the first N versioned sections
$keptVersioned = array_slice($versioned, 0, $keep);
$droppedVersioned = array_slice($versioned, $keep);
// Report
echo "CHANGELOG: {$totalVersioned} versioned entries found\n";
echo " Keeping: {$keep} most recent\n";
echo " Pruning: {$pruned} old entries\n";
foreach ($droppedVersioned as $section) {
$heading = trim($section['heading']);
echo " - {$heading}\n";
}
if ($dryRun) {
echo "\n(dry-run) No changes written\n";
exit(0);
}
// Rebuild the file
$output = implode("\n", $header);
if ($unreleased !== null) {
$output .= implode("\n", $unreleased['lines']) . "\n";
}
foreach ($keptVersioned as $section) {
$output .= implode("\n", $section['lines']) . "\n";
}
// Clean up excessive blank lines at end
$output = rtrim($output) . "\n";
file_put_contents($changelog, $output);
echo "\nCHANGELOG pruned: removed {$pruned} old entries\n";
exit(0);
+38 -81
View File
@@ -8,17 +8,21 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_dashboard.php * PATH: /cli/client_dashboard.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Generate unified client dashboard HTML * BRIEF: Generate unified client dashboard HTML
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ClientDashboard require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class ClientDashboardCli extends CliFramework
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
@@ -29,29 +33,47 @@ final class ClientDashboard
private int $sslWarnDays = 30; private int $sslWarnDays = 30;
private int $httpTimeout = 10; private int $httpTimeout = 10;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Generate unified client dashboard HTML');
$this->addArgument('--token', 'Gitea token (or MOKOGITEA_TOKEN)', '');
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
$this->addArgument('--org', 'Primary org (default: MokoConsulting)', 'MokoConsulting');
$this->addArgument('--output', 'Output HTML file (default: stdout)', '');
$this->addArgument('-o', 'Output HTML file (alias)', '');
$this->addArgument('--no-ssl', 'Skip SSL checks', false);
$this->addArgument('--no-uptime', 'Skip HTTP uptime checks', false);
$this->addArgument('--ssl-warn-days', 'SSL warning days (default: 30)', '30');
}
protected function run(): int
{
$this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$this->token = $this->getArgument('--token');
$this->org = $this->getArgument('--org');
$this->outputFile = $this->getArgument('--output') ?: $this->getArgument('-o');
$this->checkSsl = !$this->getArgument('--no-ssl');
$this->checkUptime = !$this->getArgument('--no-uptime');
$this->sslWarnDays = (int) $this->getArgument('--ssl-warn-days');
if ($this->token === '') { if ($this->token === '') {
$this->token = getenv('MOKOGITEA_TOKEN') ?: ''; $this->token = getenv('MOKOGITEA_TOKEN') ?: '';
} }
if ($this->token === '') { if ($this->token === '') {
$this->log('ERROR: --token or MOKOGITEA_TOKEN required.'); $this->log('ERROR', '--token or MOKOGITEA_TOKEN required.');
$this->printUsage();
return 1; return 1;
} }
$this->log('Gathering client data...'); $this->log('INFO', 'Gathering client data...');
$clients = $this->discoverClients(); $clients = $this->discoverClients();
if ($clients === null) { if ($clients === null) {
$this->log('ERROR: Could not fetch client repos.'); $this->log('ERROR', 'Could not fetch client repos.');
return 1; return 1;
} }
$this->log('Found ' . count($clients) . ' client(s).'); $this->log('INFO', 'Found ' . count($clients) . ' client(s).');
foreach ($clients as &$client) { foreach ($clients as &$client) {
$this->enrichClient($client); $this->enrichClient($client);
@@ -63,7 +85,7 @@ final class ClientDashboard
if ($this->outputFile !== '') { if ($this->outputFile !== '') {
file_put_contents($this->outputFile, $html); file_put_contents($this->outputFile, $html);
$this->log("Dashboard: {$this->outputFile}"); $this->log('INFO', "Dashboard: {$this->outputFile}");
} else { } else {
fwrite(STDOUT, $html); fwrite(STDOUT, $html);
} }
@@ -151,9 +173,8 @@ final class ClientDashboard
private function enrichClient(array &$client): void private function enrichClient(array &$client): void
{ {
$repo = $client['repo']; $repo = $client['repo'];
$this->log(" Checking {$client['name']}..."); $this->log('INFO', " Checking {$client['name']}...");
// Fetch variables
$resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables"); $resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables");
$vars = []; $vars = [];
@@ -185,7 +206,6 @@ final class ClientDashboard
} }
} }
// SSL
$client['ssl_expiry'] = null; $client['ssl_expiry'] = null;
$client['ssl_days'] = null; $client['ssl_days'] = null;
$client['ssl_status'] = 'unknown'; $client['ssl_status'] = 'unknown';
@@ -212,7 +232,6 @@ final class ClientDashboard
} }
} }
// Last release
$client['last_release'] = ''; $client['last_release'] = '';
$client['last_release_date'] = ''; $client['last_release_date'] = '';
$relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1"); $relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1");
@@ -461,69 +480,7 @@ CARD;
curl_close($ch); curl_close($ch);
return ['code' => $code, 'body' => $body]; return ['code' => $code, 'body' => $body];
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--output':
case '-o':
$this->outputFile = $args[++$i] ?? '';
break;
case '--no-ssl':
$this->checkSsl = false;
break;
case '--no-uptime':
$this->checkUptime = false;
break;
case '--ssl-warn-days':
$this->sslWarnDays = (int) ($args[++$i] ?? 30);
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown arg: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log('Usage: client_dashboard.php --token TOKEN [options]');
$this->log('');
$this->log('Generate unified client status dashboard (HTML).');
$this->log('');
$this->log('Options:');
$this->log(' --token <token> Gitea token (or MOKOGITEA_TOKEN)');
$this->log(' --gitea-url <url> Gitea URL');
$this->log(' --org <org> Primary org (default: MokoConsulting)');
$this->log(' -o, --output <file> Output HTML file (default: stdout)');
$this->log(' --no-ssl Skip SSL checks');
$this->log(' --no-uptime Skip HTTP uptime checks');
$this->log(' --ssl-warn-days <n> SSL warning days (default: 30)');
$this->log(' --help, -h Show this help');
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new ClientDashboard(); $app = new ClientDashboardCli();
exit($app->run()); exit($app->execute());
+182 -172
View File
@@ -1,188 +1,198 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_health_check.php * PATH: /cli/client_health_check.php
* BRIEF: Verify a client site's update server, installed version, and release availability * BRIEF: Verify a client site's update server, installed version, and release availability
*
* Usage:
* php client_health_check.php --update-url URL
* php client_health_check.php --path /repo --github-output
*
* Options:
* --path Repository root (reads update server URL from manifest)
* --update-url Update server XML URL (overrides manifest)
* --site-url Live site URL for version checking via Joomla API (optional)
* --api-token Joomla API token for site-url (optional)
* --github-output Export results to $GITHUB_OUTPUT
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$updateUrl = null;
$siteUrl = null;
$apiToken = null;
$ghOutput = false;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--update-url' && isset($argv[$i + 1])) $updateUrl = $argv[$i + 1]; class ClientHealthCheckCli extends CliFramework
if ($arg === '--site-url' && isset($argv[$i + 1])) $siteUrl = $argv[$i + 1]; {
if ($arg === '--api-token' && isset($argv[$i + 1])) $apiToken = $argv[$i + 1]; protected function configure(): void
if ($arg === '--github-output') $ghOutput = true; {
$this->setDescription('Verify a client site\'s update server, installed version, and release availability');
$this->addArgument('--path', 'Repository root (reads update server URL from manifest)', '.');
$this->addArgument('--update-url', 'Update server XML URL (overrides manifest)', '');
$this->addArgument('--site-url', 'Live site URL for version checking via Joomla API', '');
$this->addArgument('--api-token', 'Joomla API token for site-url', '');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$updateUrl = $this->getArgument('--update-url');
$siteUrl = $this->getArgument('--site-url');
$apiToken = $this->getArgument('--api-token');
$ghOutput = $this->getArgument('--github-output');
$root = realpath($path) ?: $path;
$checks = [];
// -- Resolve update server URL from manifest --
if ($updateUrl === '') {
$updateUrl = null;
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f);
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
$updateUrl = trim($m[1]);
break 2;
}
}
}
}
if ($updateUrl === null || $updateUrl === '') {
$this->log('ERROR', 'No update server URL found. Use --update-url or provide a manifest with <updateservers>.');
return 1;
}
echo "Update server: {$updateUrl}\n\n";
// -- Check 1: Update server accessible --
echo "--- Update Server ---\n";
$ch = curl_init($updateUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 && !empty($response)) {
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
$checks['update_server'] = 'pass';
} else {
echo " FAIL: HTTP {$httpCode}\n";
$checks['update_server'] = 'fail';
}
// -- Check 2: Parse updates.xml for stable version --
$stableVersion = null;
$downloadUrl = null;
if (!empty($response)) {
$sections = preg_split('/<update>/', $response);
foreach ($sections as $section) {
if (strpos($section, '<tag>stable</tag>') !== false) {
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
$stableVersion = $m[1];
}
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
$downloadUrl = trim($m[1]);
}
break;
}
}
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
$stableVersion = $m[1];
}
}
echo "\n--- Stable Release ---\n";
if ($stableVersion !== null) {
echo " Version: {$stableVersion}\n";
$checks['stable_version'] = $stableVersion;
} else {
echo " FAIL: Could not parse stable version\n";
$checks['stable_version'] = 'fail';
}
// -- Check 3: Download URL accessible --
if ($downloadUrl !== null) {
echo "\n--- Download URL ---\n";
$ch = curl_init($downloadUrl);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
]);
curl_exec($ch);
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
curl_close($ch);
if ($dlCode === 200) {
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
$checks['download'] = 'pass';
} else {
echo " FAIL: HTTP {$dlCode}\n";
$checks['download'] = 'fail';
}
}
// -- Check 4: Site version (optional) --
if ($siteUrl !== '' && $apiToken !== '') {
echo "\n--- Site Version ---\n";
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
"X-Joomla-Token: {$apiToken}",
'Accept: application/json',
],
]);
$siteResponse = curl_exec($ch);
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($siteCode === 200) {
echo " API accessible (HTTP {$siteCode})\n";
$checks['site_api'] = 'pass';
} else {
echo " WARN: Site API returned HTTP {$siteCode}\n";
$checks['site_api'] = 'warn';
}
}
// -- Summary --
echo "\n=== Health Check Summary ===\n";
$failed = 0;
foreach ($checks as $name => $result) {
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
if ($result === 'fail') {
$failed++;
}
echo " {$icon}: {$name} = {$result}\n";
}
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND);
file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND);
file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND);
}
}
return $failed > 0 ? 1 : 0;
}
} }
$root = realpath($path) ?: $path; $app = new ClientHealthCheckCli();
$checks = []; exit($app->execute());
// ── Resolve update server URL from manifest ─────────────────────────────
if ($updateUrl === null) {
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) continue;
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f);
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
$updateUrl = trim($m[1]);
break 2;
}
}
}
}
if ($updateUrl === null) {
fwrite(STDERR, "No update server URL found. Use --update-url or provide a manifest with <updateservers>.\n");
exit(1);
}
echo "Update server: {$updateUrl}\n\n";
// ── Check 1: Update server accessible ───────────────────────────────────
echo "--- Update Server ---\n";
$ch = curl_init($updateUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 && !empty($response)) {
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
$checks['update_server'] = 'pass';
} else {
echo " FAIL: HTTP {$httpCode}\n";
$checks['update_server'] = 'fail';
}
// ── Check 2: Parse updates.xml for stable version ───────────────────────
$stableVersion = null;
$downloadUrl = null;
if (!empty($response)) {
$sections = preg_split('/<update>/', $response);
foreach ($sections as $section) {
if (strpos($section, '<tag>stable</tag>') !== false) {
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
$stableVersion = $m[1];
}
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
$downloadUrl = trim($m[1]);
}
break;
}
}
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
$stableVersion = $m[1];
}
}
echo "\n--- Stable Release ---\n";
if ($stableVersion !== null) {
echo " Version: {$stableVersion}\n";
$checks['stable_version'] = $stableVersion;
} else {
echo " FAIL: Could not parse stable version\n";
$checks['stable_version'] = 'fail';
}
// ── Check 3: Download URL accessible ────────────────────────────────────
if ($downloadUrl !== null) {
echo "\n--- Download URL ---\n";
$ch = curl_init($downloadUrl);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
]);
curl_exec($ch);
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
curl_close($ch);
if ($dlCode === 200) {
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
$checks['download'] = 'pass';
} else {
echo " FAIL: HTTP {$dlCode}\n";
$checks['download'] = 'fail';
}
}
// ── Check 4: Site version (optional) ────────────────────────────────────
if ($siteUrl !== null && $apiToken !== null) {
echo "\n--- Site Version ---\n";
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
"X-Joomla-Token: {$apiToken}",
'Accept: application/json',
],
]);
$siteResponse = curl_exec($ch);
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($siteCode === 200) {
echo " API accessible (HTTP {$siteCode})\n";
$checks['site_api'] = 'pass';
} else {
echo " WARN: Site API returned HTTP {$siteCode}\n";
$checks['site_api'] = 'warn';
}
}
// ── Summary ─────────────────────────────────────────────────────────────
echo "\n=== Health Check Summary ===\n";
$failed = 0;
foreach ($checks as $name => $result) {
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
if ($result === 'fail') $failed++;
echo " {$icon}: {$name} = {$result}\n";
}
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND);
file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND);
file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND);
}
}
exit($failed > 0 ? 1 : 0);
+202 -259
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,328 +8,270 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_inventory.php * PATH: /cli/client_inventory.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Discover and list all client-waas repos with their server configuration status * BRIEF: Discover and list all client-waas repos with their server configuration status
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ClientInventory require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class ClientInventoryCli extends CliFramework
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
private bool $jsonOutput = false; private bool $jsonOutput = false;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Discover and list all client-waas repos with their server configuration status');
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--json', 'Output results as JSON', false);
}
if ($this->token === '') protected function run(): int
{ {
$this->log('ERROR: --token is required.'); $this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$this->printUsage(); $this->token = $this->getArgument('--token');
return 1; $this->jsonOutput = (bool) $this->getArgument('--json');
}
$this->log("Scanning Gitea instance: {$this->giteaUrl}"); if ($this->token === '') {
$this->log('ERROR', '--token is required.');
return 1;
}
// Step 1: List all orgs $this->log('INFO', "Scanning Gitea instance: {$this->giteaUrl}");
$orgs = $this->fetchOrgs();
if ($orgs === null) // Step 1: List all orgs
{ $orgs = $this->fetchOrgs();
$this->log('ERROR: Failed to fetch organizations.');
return 1;
}
$this->log('Found ' . count($orgs) . ' organization(s).'); if ($orgs === null) {
$this->log('ERROR', 'Failed to fetch organizations.');
return 1;
}
// Step 2 & 3: For each org, find client-waas repos $this->log('INFO', 'Found ' . count($orgs) . ' organization(s).');
$inventory = [];
foreach ($orgs as $org) // Step 2 & 3: For each org, find client-waas repos
{ $inventory = [];
$orgName = $org['username'] ?? $org['name'] ?? '';
if ($orgName === '') foreach ($orgs as $org) {
{ $orgName = $org['username'] ?? $org['name'] ?? '';
continue;
}
$repos = $this->fetchOrgRepos($orgName); if ($orgName === '') {
continue;
}
if ($repos === null) $repos = $this->fetchOrgRepos($orgName);
{
$this->log("WARNING: Could not fetch repos for org: {$orgName}");
continue;
}
foreach ($repos as $repo) if ($repos === null) {
{ $this->log('WARNING', "Could not fetch repos for org: {$orgName}");
$repoName = $repo['name'] ?? ''; continue;
}
if (strpos($repoName, 'client-waas') === false) foreach ($repos as $repo) {
{ $repoName = $repo['name'] ?? '';
continue;
}
$hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']); if (strpos($repoName, 'client-waas') === false) {
$hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']); continue;
}
$lastPush = $repo['updated_at'] ?? 'unknown'; $hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']);
$hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']);
if ($lastPush !== 'unknown') $lastPush = $repo['updated_at'] ?? 'unknown';
{
$lastPush = substr($lastPush, 0, 19);
}
$status = 'OK'; if ($lastPush !== 'unknown') {
$lastPush = substr($lastPush, 0, 19);
}
if (!$hasDevConfig && !$hasLiveConfig) $status = 'OK';
{
$status = 'UNCONFIGURED';
}
elseif (!$hasDevConfig)
{
$status = 'NO DEV';
}
elseif (!$hasLiveConfig)
{
$status = 'NO LIVE';
}
$inventory[] = [ if (!$hasDevConfig && !$hasLiveConfig) {
'org' => $orgName, $status = 'UNCONFIGURED';
'repo' => $repoName, } elseif (!$hasDevConfig) {
'has_dev_config' => $hasDevConfig, $status = 'NO DEV';
'has_live_config' => $hasLiveConfig, } elseif (!$hasLiveConfig) {
'last_push' => $lastPush, $status = 'NO LIVE';
'status' => $status, }
];
}
}
// Output results $inventory[] = [
if ($this->jsonOutput) 'org' => $orgName,
{ 'repo' => $repoName,
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); 'has_dev_config' => $hasDevConfig,
return 0; 'has_live_config' => $hasLiveConfig,
} 'last_push' => $lastPush,
'status' => $status,
];
}
}
if (count($inventory) === 0) // Output results
{ if ($this->jsonOutput) {
$this->log('No client-waas repos found.'); fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return 0; return 0;
} }
// Print table if (count($inventory) === 0) {
$this->log(''); $this->log('INFO', 'No client-waas repos found.');
$this->log(sprintf( return 0;
'%-20s | %-35s | %-10s | %-11s | %-19s | %s', }
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status'
));
$this->log(str_repeat('-', 120));
foreach ($inventory as $entry) // Print table
{ $this->log('INFO', '');
$this->log(sprintf( $this->log('INFO', sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s', '%-20s | %-35s | %-10s | %-11s | %-19s | %s',
$entry['org'], 'Org',
$entry['repo'], 'Repo',
$entry['has_dev_config'] ? 'Yes' : 'No', 'Dev Config',
$entry['has_live_config'] ? 'Yes' : 'No', 'Live Config',
$entry['last_push'], 'Last Push',
$entry['status'] 'Status'
)); ));
} $this->log('INFO', str_repeat('-', 120));
$this->log(''); foreach ($inventory as $entry) {
$this->log('Total: ' . count($inventory) . ' client-waas repo(s).'); $this->log('INFO', sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
$entry['org'],
$entry['repo'],
$entry['has_dev_config'] ? 'Yes' : 'No',
$entry['has_live_config'] ? 'Yes' : 'No',
$entry['last_push'],
$entry['status']
));
}
return 0; $this->log('INFO', '');
} $this->log('INFO', 'Total: ' . count($inventory) . ' client-waas repo(s).');
private function parseArgs(): void return 0;
{ }
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) private function fetchOrgs(): ?array
{ {
switch ($args[$i]) // Try admin endpoint first, fall back to user-visible orgs
{ $response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50');
case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break;
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--json':
$this->jsonOutput = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
}
private function printUsage(): void if ($response['code'] >= 200 && $response['code'] < 300) {
{ $data = json_decode($response['body'], true);
$this->log('Usage: client_inventory.php --token <token> [options]');
$this->log('');
$this->log('Options:');
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
$this->log(' --token <token> Gitea API token');
$this->log(' --json Output results as JSON');
$this->log(' --help, -h Show this help');
}
private function fetchOrgs(): ?array if (is_array($data)) {
{ return $data;
// Try admin endpoint first, fall back to user-visible orgs }
$response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50'); }
if ($response['code'] >= 200 && $response['code'] < 300) $this->log('INFO', 'Admin orgs endpoint unavailable, falling back to user orgs...');
{
$data = json_decode($response['body'], true);
if (is_array($data)) $response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50');
{
return $data;
}
}
$this->log('Admin orgs endpoint unavailable, falling back to user orgs...'); if ($response['code'] >= 200 && $response['code'] < 300) {
$data = json_decode($response['body'], true);
$response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50'); if (is_array($data)) {
return $data;
}
}
if ($response['code'] >= 200 && $response['code'] < 300) return null;
{ }
$data = json_decode($response['body'], true);
if (is_array($data)) private function fetchOrgRepos(string $org): ?array
{ {
return $data; $page = 1;
} $allRepos = [];
}
return null; while (true) {
} $response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}");
private function fetchOrgRepos(string $org): ?array if ($response['code'] < 200 || $response['code'] >= 300) {
{ return $page === 1 ? null : $allRepos;
$page = 1; }
$allRepos = [];
while (true) $data = json_decode($response['body'], true);
{
$response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}");
if ($response['code'] < 200 || $response['code'] >= 300) if (!is_array($data) || count($data) === 0) {
{ break;
return $page === 1 ? null : $allRepos; }
}
$data = json_decode($response['body'], true); $allRepos = array_merge($allRepos, $data);
$page++;
}
if (!is_array($data) || count($data) === 0) return $allRepos;
{ }
break;
}
$allRepos = array_merge($allRepos, $data); private function checkVariables(string $org, string $repo, array $requiredVars): bool
$page++; {
} $response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables");
return $allRepos; if ($response['code'] < 200 || $response['code'] >= 300) {
} return false;
}
private function checkVariables(string $org, string $repo, array $requiredVars): bool $data = json_decode($response['body'], true);
{
$response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables");
if ($response['code'] < 200 || $response['code'] >= 300) if (!is_array($data)) {
{ return false;
return false; }
}
$data = json_decode($response['body'], true); $existingVars = [];
if (!is_array($data)) foreach ($data as $variable) {
{ if (isset($variable['name'])) {
return false; $existingVars[] = $variable['name'];
} }
}
$existingVars = []; foreach ($requiredVars as $var) {
if (!in_array($var, $existingVars, true)) {
return false;
}
}
foreach ($data as $variable) return true;
{ }
if (isset($variable['name']))
{
$existingVars[] = $variable['name'];
}
}
foreach ($requiredVars as $var) private function apiRequest(string $method, string $endpoint, ?string $body = null): array
{ {
if (!in_array($var, $existingVars, true)) $url = $this->giteaUrl . $endpoint;
{
return false;
}
}
return true; $ch = curl_init();
} curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$this->token}",
]);
private function apiRequest(string $method, string $endpoint, ?string $body = null): array if ($body !== null) {
{ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
$url = $this->giteaUrl . $endpoint; }
$ch = curl_init(); $responseBody = curl_exec($ch);
curl_setopt($ch, CURLOPT_URL, $url); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$this->token}",
]);
if ($body !== null) if (curl_errno($ch)) {
{ $error = curl_error($ch);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_close($ch);
}
$responseBody = curl_exec($ch); return ['code' => 0, 'body' => "cURL error: {$error}"];
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); }
if (curl_errno($ch)) curl_close($ch);
{
$error = curl_error($ch);
curl_close($ch);
return ['code' => 0, 'body' => "cURL error: {$error}"]; return ['code' => $httpCode, 'body' => $responseBody];
} }
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new ClientInventory(); $app = new ClientInventoryCli();
exit($app->run()); exit($app->execute());
+71 -114
View File
@@ -8,17 +8,21 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_provision.php * PATH: /cli/client_provision.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Provision a new client environment end-to-end * BRIEF: Provision a new client environment end-to-end
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ClientProvision require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class ClientProvisionCli extends CliFramework
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $giteaToken = ''; private string $giteaToken = '';
@@ -26,24 +30,30 @@ final class ClientProvision
private string $grafanaToken = ''; private string $grafanaToken = '';
private string $configFile = ''; private string $configFile = '';
private string $step = ''; private string $step = '';
private bool $dryRun = false;
/** @var array<string, mixed> */ /** @var array<string, mixed> */
private array $config = []; private array $config = [];
private string $org = ''; private string $org = '';
private string $repoName = ''; private string $repoName = '';
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Provision a new client environment end-to-end');
$this->addArgument('--config', 'Client config JSON', '');
$this->addArgument('--step', 'Run one step: repo, variables, secrets, monitoring, summary', '');
}
protected function run(): int
{
$this->configFile = $this->getArgument('--config');
$this->step = $this->getArgument('--step');
if ($this->configFile === '') { if ($this->configFile === '') {
$this->log('ERROR: --config is required.'); $this->log('ERROR', '--config is required.');
$this->printUsage();
return 1; return 1;
} }
if (!file_exists($this->configFile)) { if (!file_exists($this->configFile)) {
$this->log("ERROR: Not found: {$this->configFile}"); $this->log('ERROR', "Not found: {$this->configFile}");
return 1; return 1;
} }
@@ -51,7 +61,7 @@ final class ClientProvision
$this->config = json_decode($json, true); $this->config = json_decode($json, true);
if (!is_array($this->config)) { if (!is_array($this->config)) {
$this->log('ERROR: Invalid JSON in config file.'); $this->log('ERROR', 'Invalid JSON in config file.');
return 1; return 1;
} }
@@ -65,7 +75,7 @@ final class ClientProvision
?? $this->giteaUrl; ?? $this->giteaUrl;
if ($this->giteaToken === '') { if ($this->giteaToken === '') {
$this->log('ERROR: gitea_token or MOKOGITEA_TOKEN required.'); $this->log('ERROR', 'gitea_token or MOKOGITEA_TOKEN required.');
return 1; return 1;
} }
@@ -73,21 +83,21 @@ final class ClientProvision
$clientName = $this->config['name'] ?? ''; $clientName = $this->config['name'] ?? '';
if ($this->org === '' || $clientName === '') { if ($this->org === '' || $clientName === '') {
$this->log('ERROR: "org" and "name" required in config.'); $this->log('ERROR', '"org" and "name" required in config.');
return 1; return 1;
} }
$this->repoName = 'client-waas-' . $clientName; $this->repoName = 'client-waas-' . $clientName;
$this->log("=== Client Provisioning: {$clientName} ==="); $this->log('INFO', "=== Client Provisioning: {$clientName} ===");
$this->log(" Org: {$this->org}"); $this->log('INFO', " Org: {$this->org}");
$this->log(" Repo: {$this->repoName}"); $this->log('INFO', " Repo: {$this->repoName}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log(' Mode: DRY RUN'); $this->log('INFO', ' Mode: DRY RUN');
} }
$this->log(''); echo "\n";
$steps = [ $steps = [
'repo' => 'createRepo', 'repo' => 'createRepo',
@@ -116,7 +126,7 @@ final class ClientProvision
private function createRepo(): int private function createRepo(): int
{ {
$this->log('[1/5] Creating repository...'); $this->log('INFO', '[1/5] Creating repository...');
$check = $this->giteaApi( $check = $this->giteaApi(
'GET', 'GET',
@@ -124,14 +134,12 @@ final class ClientProvision
); );
if ($check['code'] === 200) { if ($check['code'] === 200) {
$this->log(" SKIP: repo already exists"); $this->log('INFO', ' SKIP: repo already exists');
return 0; return 0;
} }
if ($this->dryRun) { if ($this->dryRun) {
$this->log( $this->log('INFO', " WOULD CREATE: {$this->org}/{$this->repoName}");
" WOULD CREATE: {$this->org}/{$this->repoName}"
);
return 0; return 0;
} }
@@ -153,11 +161,11 @@ final class ClientProvision
); );
if ($resp['code'] < 200 || $resp['code'] >= 300) { if ($resp['code'] < 200 || $resp['code'] >= 300) {
$this->log(" ERROR: HTTP {$resp['code']}"); $this->log('ERROR', "HTTP {$resp['code']}");
return 1; return 1;
} }
$this->log(' OK: Repo created'); $this->log('INFO', ' OK: Repo created');
$this->giteaApi( $this->giteaApi(
'POST', 'POST',
@@ -168,19 +176,19 @@ final class ClientProvision
]) ])
); );
$this->log(' OK: dev branch created'); $this->log('INFO', ' OK: dev branch created');
return 0; return 0;
} }
private function setVariables(): int private function setVariables(): int
{ {
$this->log('[2/5] Setting repo variables...'); $this->log('INFO', '[2/5] Setting repo variables...');
$vars = $this->config['variables'] ?? []; $vars = $this->config['variables'] ?? [];
if (empty($vars)) { if (empty($vars)) {
$this->log(' SKIP: No variables in config'); $this->log('INFO', ' SKIP: No variables in config');
return 0; return 0;
} }
@@ -192,16 +200,16 @@ final class ClientProvision
if ($this->dryRun) { if ($this->dryRun) {
$display = strlen($value) > 40 $display = strlen($value) > 40
? substr($value, 0, 37) . '...' : $value; ? substr($value, 0, 37) . '...' : $value;
$this->log(" WOULD SET: {$name} = {$display}"); $this->log('INFO', " WOULD SET: {$name} = {$display}");
continue; continue;
} }
$ok = $this->setOrCreateVariable($api, $name, $value); $ok = $this->setOrCreateVariable($api, $name, $value);
if ($ok) { if ($ok) {
$this->log(" OK: {$name}"); $this->log('INFO', " OK: {$name}");
} else { } else {
$this->log(" ERROR: {$name}"); $this->log('ERROR', " {$name}");
$errors++; $errors++;
} }
} }
@@ -211,12 +219,12 @@ final class ClientProvision
private function setSecrets(): int private function setSecrets(): int
{ {
$this->log('[3/5] Setting repo secrets...'); $this->log('INFO', '[3/5] Setting repo secrets...');
$secrets = $this->config['secrets'] ?? []; $secrets = $this->config['secrets'] ?? [];
if (empty($secrets)) { if (empty($secrets)) {
$this->log(' SKIP: No secrets in config'); $this->log('INFO', ' SKIP: No secrets in config');
return 0; return 0;
} }
@@ -229,7 +237,7 @@ final class ClientProvision
$keyPath = substr($value, 1); $keyPath = substr($value, 1);
if (!file_exists($keyPath)) { if (!file_exists($keyPath)) {
$this->log(" ERROR: {$name} file not found: {$keyPath}"); $this->log('ERROR', " {$name} file not found: {$keyPath}");
$errors++; $errors++;
continue; continue;
} }
@@ -238,7 +246,7 @@ final class ClientProvision
} }
if ($this->dryRun) { if ($this->dryRun) {
$this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")"); $this->log('INFO', " WOULD SET: {$name} (len: " . strlen($value) . ")");
continue; continue;
} }
@@ -249,9 +257,9 @@ final class ClientProvision
); );
if ($resp['code'] >= 200 && $resp['code'] < 300) { if ($resp['code'] >= 200 && $resp['code'] < 300) {
$this->log(" OK: {$name}"); $this->log('INFO', " OK: {$name}");
} else { } else {
$this->log(" ERROR: {$name} (HTTP {$resp['code']})"); $this->log('ERROR', " {$name} (HTTP {$resp['code']})");
$errors++; $errors++;
} }
} }
@@ -261,12 +269,12 @@ final class ClientProvision
private function setupMonitoring(): int private function setupMonitoring(): int
{ {
$this->log('[4/5] Setting up monitoring...'); $this->log('INFO', '[4/5] Setting up monitoring...');
$mon = $this->config['monitoring'] ?? []; $mon = $this->config['monitoring'] ?? [];
if (empty($mon)) { if (empty($mon)) {
$this->log(' SKIP: No monitoring config'); $this->log('INFO', ' SKIP: No monitoring config');
return 0; return 0;
} }
@@ -291,10 +299,10 @@ final class ClientProvision
$urlStr = implode("\n", $urls); $urlStr = implode("\n", $urls);
if ($this->dryRun) { if ($this->dryRun) {
$this->log(" WOULD SET: MONITORED_URLS"); $this->log('INFO', ' WOULD SET: MONITORED_URLS');
} else { } else {
$this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr); $this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr);
$this->log(' OK: MONITORED_URLS'); $this->log('INFO', ' OK: MONITORED_URLS');
} }
} }
@@ -302,10 +310,10 @@ final class ClientProvision
$domainStr = implode("\n", $domains); $domainStr = implode("\n", $domains);
if ($this->dryRun) { if ($this->dryRun) {
$this->log(" WOULD SET: MONITORED_DOMAINS"); $this->log('INFO', ' WOULD SET: MONITORED_DOMAINS');
} else { } else {
$this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr); $this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr);
$this->log(' OK: MONITORED_DOMAINS'); $this->log('INFO', ' OK: MONITORED_DOMAINS');
} }
} }
@@ -315,19 +323,19 @@ final class ClientProvision
private function pushGrafanaDashboard(string $file, string $folder): void private function pushGrafanaDashboard(string $file, string $folder): void
{ {
if (!file_exists($file)) { if (!file_exists($file)) {
$this->log(" WARN: Dashboard not found: {$file}"); $this->warning("Dashboard not found: {$file}");
return; return;
} }
if ($this->dryRun) { if ($this->dryRun) {
$this->log(" WOULD PUSH: dashboard to \"{$folder}\""); $this->log('INFO', " WOULD PUSH: dashboard to \"{$folder}\"");
return; return;
} }
$dashboard = json_decode(file_get_contents($file), true); $dashboard = json_decode(file_get_contents($file), true);
if (!is_array($dashboard)) { if (!is_array($dashboard)) {
$this->log(' ERROR: Invalid dashboard JSON'); $this->log('ERROR', 'Invalid dashboard JSON');
return; return;
} }
@@ -346,9 +354,9 @@ final class ClientProvision
if ($resp['code'] === 200) { if ($resp['code'] === 200) {
$data = json_decode($resp['body'], true); $data = json_decode($resp['body'], true);
$this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")"); $this->log('INFO', " OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
} else { } else {
$this->log(" ERROR: Dashboard push (HTTP {$resp['code']})"); $this->log('ERROR', " Dashboard push (HTTP {$resp['code']})");
} }
} }
@@ -379,20 +387,19 @@ final class ClientProvision
{ {
$vars = $this->config['variables'] ?? []; $vars = $this->config['variables'] ?? [];
$secrets = $this->config['secrets'] ?? []; $secrets = $this->config['secrets'] ?? [];
$clientName = $this->config['name'] ?? '';
$this->log(''); echo "\n";
$this->log('[5/5] Provisioning summary'); $this->log('INFO', '[5/5] Provisioning summary');
$this->log(str_repeat('=', 60)); echo str_repeat('=', 60) . "\n";
$this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}"); echo " Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}\n";
$this->log(' Variables: ' . count($vars) . ' set'); echo ' Variables: ' . count($vars) . " set\n";
$this->log(' Secrets: ' . count($secrets) . ' set'); echo ' Secrets: ' . count($secrets) . " set\n";
$this->log(''); echo "\n";
$this->log('Next steps:'); echo "Next steps:\n";
$this->log(' 1. Clone and customize the Joomla template'); echo " 1. Clone and customize the Joomla template\n";
$this->log(' 2. Push to dev to trigger dev deployment'); echo " 2. Push to dev to trigger dev deployment\n";
$this->log(' 3. Merge dev -> main for production release'); echo " 3. Merge dev -> main for production release\n";
$this->log(str_repeat('=', 60)); echo str_repeat('=', 60) . "\n";
return 0; return 0;
} }
@@ -419,51 +426,6 @@ final class ClientProvision
return $resp['code'] >= 200 && $resp['code'] < 300; return $resp['code'] >= 200 && $resp['code'] < 300;
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case '--config':
$this->configFile = $args[++$i] ?? '';
break;
case '--step':
$this->step = $args[++$i] ?? '';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown arg: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log('Usage: client_provision.php --config <file.json> [options]');
$this->log('');
$this->log('Provision a new client environment end-to-end.');
$this->log('');
$this->log('Options:');
$this->log(' --config <file> Client config JSON');
$this->log(' --step <name> Run one step: repo, variables, secrets, monitoring, summary');
$this->log(' --dry-run Preview without changes');
$this->log(' --help, -h Show this help');
$this->log('');
$this->log('Environment variables:');
$this->log(' MOKOGITEA_TOKEN Gitea API token');
$this->log(' GRAFANA_URL Grafana instance URL');
$this->log(' GRAFANA_TOKEN Grafana API token');
}
private function giteaApi( private function giteaApi(
string $method, string $method,
string $endpoint, string $endpoint,
@@ -523,12 +485,7 @@ final class ClientProvision
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new ClientProvision(); $app = new ClientProvisionCli();
exit($app->run()); exit($app->execute());
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/completion.php
* BRIEF: Generate bash/zsh tab completion scripts for bin/moko
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class CompletionCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Generate bash/zsh tab completion scripts for bin/moko');
$this->addArgument('--shell', 'Shell type: bash or zsh', 'bash');
}
protected function run(): int
{
$shell = $this->getArgument('--shell');
// Also accept positional-style: check raw argv for bash/zsh
global $argv;
foreach ($argv as $arg) {
if (in_array($arg, ['bash', 'zsh'], true)) {
$shell = $arg;
break;
}
}
// Extract command names from bin/moko COMMAND_MAP using regex (no eval).
$mokoFile = dirname(__DIR__) . '/bin/moko';
$content = file_get_contents($mokoFile);
// Isolate the COMMAND_MAP block, then extract keys.
if (!preg_match('/const COMMAND_MAP\s*=\s*\[(.+?)\];/s', $content, $block)) {
$this->log('ERROR', 'Could not find COMMAND_MAP in bin/moko');
return 1;
}
// Match 'command-name' => 'path' entries within the block.
if (!preg_match_all("/'([a-z][a-z0-9:_-]*)'\s*=>/m", $block[1], $matches)) {
$this->log('ERROR', 'Could not parse command names from COMMAND_MAP');
return 1;
}
$commandNames = array_unique($matches[1]);
sort($commandNames);
// Common flags supported by CliFramework.
$commonFlags = ['--help', '--verbose', '--quiet', '--dry-run', '--json', '--no-color', '--path'];
if ($shell === 'zsh') {
$this->generateZsh($commandNames, $commonFlags);
} else {
$this->generateBash($commandNames, $commonFlags);
}
return 0;
}
// -- Generators --
private function generateBash(array $commands, array $flags): void
{
$cmdList = implode(' ', $commands);
$flagList = implode(' ', $flags);
echo <<<BASH
# moko bash completion — generated by: php bin/moko completion bash
_moko_complete() {
local cur prev commands flags
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
commands="{$cmdList}"
flags="{$flagList}"
# Complete commands (first argument after 'moko')
if [[ \$COMP_CWORD -eq 1 ]] || [[ \$COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "php" ]]; then
COMPREPLY=( \$(compgen -W "\$commands list help" -- "\$cur") )
return 0
fi
# Complete flags
if [[ "\$cur" == -* ]]; then
COMPREPLY=( \$(compgen -W "\$flags" -- "\$cur") )
return 0
fi
# Complete --path with directories
if [[ "\$prev" == "--path" ]]; then
COMPREPLY=( \$(compgen -d -- "\$cur") )
return 0
fi
}
# Register for both direct and php invocation
complete -F _moko_complete moko
complete -F _moko_complete ./bin/moko
complete -F _moko_complete bin/moko
BASH;
}
private function generateZsh(array $commands, array $flags): void
{
$cmdLines = '';
foreach ($commands as $cmd) {
$cmdLines .= " '{$cmd}'\n";
}
$flagLines = '';
foreach ($flags as $flag) {
$desc = match ($flag) {
'--help' => 'Show help for the command',
'--verbose' => 'Show detailed output',
'--quiet' => 'Suppress non-error output',
'--dry-run' => 'Preview changes without writing',
'--json' => 'Machine-readable JSON output',
'--no-color' => 'Disable ANSI colour output',
'--path' => 'Repository root path',
default => $flag,
};
$flagLines .= " '{$flag}[{$desc}]'\n";
}
echo <<<ZSH
#compdef moko bin/moko
# moko zsh completion — generated by: php bin/moko completion zsh
_moko() {
local -a commands flags
commands=(
{$cmdLines} 'list'
'help'
)
flags=(
{$flagLines} )
if (( CURRENT == 2 )); then
_describe 'command' commands
else
_arguments '*:flags:_values "flag" \${flags[@]}'
fi
}
compdef _moko moko
compdef _moko bin/moko
ZSH;
}
}
$app = new CompletionCli();
exit($app->execute());
+420 -457
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,474 +8,436 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/create_project.php * PATH: /cli/create_project.php
* BRIEF: Create baseline GitHub Projects for repositories with standard fields and views * BRIEF: Create baseline GitHub Projects for repositories with standard fields and views
*
* USAGE
* php cli/create_project.php --repo MokoCRM # Auto-detect type, create project
* php cli/create_project.php --repo MokoCRM --type dolibarr # Force type
* php cli/create_project.php --org mokoconsulting-tech --all # All repos without projects
* php cli/create_project.php --repo MokoCRM --dry-run # Preview without changes
*/ */
declare(strict_types=1); declare(strict_types=1);
$dryRun = in_array('--dry-run', $argv); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$allMode = in_array('--all', $argv);
$org = 'mokoconsulting-tech'; use MokoCli\CliFramework;
$repoName = null;
$typeOverride = null;
foreach ($argv as $i => $arg) { class CreateProjectCli extends CliFramework
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoName = $argv[$i + 1];
}
if ($arg === '--org' && isset($argv[$i + 1])) {
$org = $argv[$i + 1];
}
if ($arg === '--type' && isset($argv[$i + 1])) {
$typeOverride = $argv[$i + 1];
}
}
if (!$repoName && !$allMode) {
fwrite(STDERR, "Usage: php create_project.php --repo <name> [--type <type>] [--dry-run]\n");
fwrite(STDERR, " php create_project.php --all [--org <org>] [--dry-run]\n");
fwrite(STDERR, "\nTypes: generic, dolibarr, joomla, nodejs, terraform, python, wordpress, mobile-app, api, documentation\n");
exit(2);
}
$config = \MokoEnterprise\Config::load();
$platform = $config->getString('platform', 'gitea');
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$api = $adapter->getApiClient();
} catch (\Exception $e) {
fwrite(STDERR, "Platform initialization failed: " . $e->getMessage() . "\n");
exit(1);
}
$token = $platform === 'gitea'
? $config->getString('gitea.token', '')
: $config->getString('github.token', '');
$repoRoot = dirname(__DIR__, 2);
$templatesDir = "{$repoRoot}/templates/projects";
// ── Always-exclude list (no project needed) ─────────────────────────────
$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private'];
// ── Platform type map ───────────────────────────────────────────────────
$PLATFORM_TO_TYPE = [
'crm-module' => 'dolibarr',
'crm-platform' => 'dolibarr',
'waas-component' => 'joomla',
'waas-library' => 'joomla',
'waas-plugin' => 'joomla',
'waas-package' => 'joomla',
'nodejs' => 'nodejs',
'terraform' => 'terraform',
'python' => 'python',
'wordpress' => 'wordpress',
'mobile' => 'mobile-app',
'api' => 'api',
'documentation' => 'documentation',
];
// ── Template file map ───────────────────────────────────────────────────
$TYPE_TO_TEMPLATE = [
'generic' => 'generic-project-definition.tf',
'dolibarr' => 'dolibarr-project-definition.tf',
'joomla' => 'joomla-project-definition.tf',
'nodejs' => 'nodejs-project-definition.tf',
'terraform' => 'terraform-project-definition.tf',
'python' => 'python-project-definition.tf',
'wordpress' => 'wordpress-project-definition.tf',
'mobile-app' => 'mobile-app-project-definition.tf',
'api' => 'api-project-definition.tf',
'documentation' => 'documentation-project-definition.tf',
];
/**
* Execute a GraphQL query (GitHub only — Gitea does not support GraphQL).
*
* @return array<string, mixed>
*/
function graphql(string $query, array $variables, string $token, string $platformName = 'gitea'): array
{ {
if ($platformName !== 'github') { /** @var string[] */
return []; private array $ALWAYS_EXCLUDE = ['mokocli', '.github-private'];
}
$payload = json_encode(['query' => $query, 'variables' => $variables]);
$ch = curl_init('https://api.github.com/graphql');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: bearer ' . $token,
'Content-Type: application/json',
'User-Agent: MokoStandards-CreateProject',
],
]);
$body = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) { /** @var array<string, string> */
fwrite(STDERR, "GraphQL request failed (HTTP {$status}): {$body}\n"); private array $PLATFORM_TO_TYPE = [
return []; 'crm-module' => 'dolibarr',
} 'crm-platform' => 'dolibarr',
'waas-component' => 'joomla',
'waas-library' => 'joomla',
'waas-plugin' => 'joomla',
'waas-package' => 'joomla',
'nodejs' => 'nodejs',
'terraform' => 'terraform',
'python' => 'python',
'wordpress' => 'wordpress',
'mobile' => 'mobile-app',
'api' => 'api',
'documentation' => 'documentation',
];
$data = json_decode($body, true) ?? []; /** @var array<string, string> */
if (!empty($data['errors'])) { private array $TYPE_TO_TEMPLATE = [
foreach ($data['errors'] as $err) { 'generic' => 'generic-project-definition.tf',
fwrite(STDERR, " GraphQL error: " . ($err['message'] ?? 'unknown') . "\n"); 'dolibarr' => 'dolibarr-project-definition.tf',
} 'joomla' => 'joomla-project-definition.tf',
} 'nodejs' => 'nodejs-project-definition.tf',
'terraform' => 'terraform-project-definition.tf',
'python' => 'python-project-definition.tf',
'wordpress' => 'wordpress-project-definition.tf',
'mobile-app' => 'mobile-app-project-definition.tf',
'api' => 'api-project-definition.tf',
'documentation' => 'documentation-project-definition.tf',
];
return $data['data'] ?? []; protected function configure(): void
{
$this->setDescription('Create baseline GitHub Projects for repositories with standard fields and views');
$this->addArgument('--repo', 'Repository name', '');
$this->addArgument('--org', 'Organization (default: mokoconsulting-tech)', 'mokoconsulting-tech');
$this->addArgument('--type', 'Force project type', '');
$this->addArgument('--all', 'Process all repos without projects', false);
}
protected function run(): int
{
$repoName = $this->getArgument('--repo') ?: null;
$org = $this->getArgument('--org');
$typeOverride = $this->getArgument('--type') ?: null;
$allMode = $this->getArgument('--all');
if (!$repoName && !$allMode) {
$this->log('ERROR', "Usage: php create_project.php --repo <name> [--type <type>] [--dry-run]");
$this->log('ERROR', " php create_project.php --all [--org <org>] [--dry-run]");
$this->log('ERROR', "Types: generic, dolibarr, joomla, nodejs, terraform, python, wordpress, mobile-app, api, documentation");
return 2;
}
$config = \MokoCli\Config::load();
$platformName = $config->getString('platform', 'gitea');
try {
$adapter = \MokoCli\PlatformAdapterFactory::create($config);
$api = $adapter->getApiClient();
} catch (\Exception $e) {
$this->log('ERROR', "Platform initialization failed: " . $e->getMessage());
return 1;
}
$token = $platformName === 'gitea'
? $config->getString('gitea.token', '')
: $config->getString('github.token', '');
$repoRoot = dirname(__DIR__, 2);
$templatesDir = "{$repoRoot}/templates/projects";
$repos = [];
if ($allMode) {
echo "Fetching repositories from {$org}...\n";
$page = 1;
do {
$batch = $this->restGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token, $api);
foreach ($batch as $r) {
if (!$r['archived'] && !in_array($r['name'], $this->ALWAYS_EXCLUDE, true)) {
$repos[] = $r['name'];
}
}
$page++;
} while (count($batch) === 100);
sort($repos);
echo "Found " . count($repos) . " repositories\n\n";
} else {
$repos = [$repoName];
}
$ownerId = $this->getOrgNodeId($org, $token);
if (empty($ownerId)) {
$this->log('ERROR', "Could not resolve org node ID for {$org}");
return 1;
}
$created = 0;
$skipped = 0;
$failed = 0;
foreach ($repos as $repo) {
echo "Processing {$repo}...\n";
[$hasProject, $existingTitle] = $this->repoHasProject($org, $repo, $token);
if ($hasProject) {
echo " Already has project: {$existingTitle} -- skipping\n";
$skipped++;
continue;
}
$type = $typeOverride;
if (!$type) {
$platform = $this->detectRepoPlatform($org, $repo, $token, $api);
$type = $this->PLATFORM_TO_TYPE[$platform] ?? 'generic';
echo " Platform: {$platform} -> type: {$type}\n";
}
$templateFile = $this->TYPE_TO_TEMPLATE[$type] ?? $this->TYPE_TO_TEMPLATE['generic'];
$template = $this->parseTemplate("{$templatesDir}/{$templateFile}");
$repoId = $this->getRepoNodeId($org, $repo, $token);
if (empty($repoId)) {
$this->log('ERROR', " Could not resolve repo node ID for {$repo}");
$failed++;
continue;
}
$ok = $this->createProject($org, $repo, $ownerId, $repoId, $template, $token);
if ($ok) {
$created++;
} else {
$failed++;
}
echo "\n";
}
echo str_repeat('-', 50) . "\n";
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
return $failed > 0 ? 1 : 0;
}
private function graphql(string $query, array $variables, string $token, string $platformName = 'gitea'): array
{
if ($platformName !== 'github') {
return [];
}
$payload = json_encode(['query' => $query, 'variables' => $variables]);
$ch = curl_init('https://api.github.com/graphql');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: bearer ' . $token,
'Content-Type: application/json',
'User-Agent: mokocli-CreateProject',
],
]);
$body = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
$this->log('ERROR', "GraphQL request failed (HTTP {$status}): {$body}");
return [];
}
$data = json_decode($body, true) ?? [];
if (!empty($data['errors'])) {
foreach ($data['errors'] as $err) {
$this->log('ERROR', " GraphQL error: " . ($err['message'] ?? 'unknown'));
}
}
return $data['data'] ?? [];
}
private function restGet(string $path, string $token, ?\MokoCli\ApiClient $apiClient = null): array
{
if ($apiClient !== null) {
try {
return $apiClient->get("/{$path}");
} catch (\Exception $e) {
return [];
}
}
return [];
}
private function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoCli\ApiClient $apiClient = null): string
{
foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) {
$data = $this->restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient);
if (!empty($data['content'])) {
$content = base64_decode($data['content']);
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
return trim($m[1], " \t\n\r\"'");
}
}
}
return '';
}
private function getRepoNodeId(string $org, string $repo, string $token): string
{
$data = $this->graphql(
'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }',
['owner' => $org, 'name' => $repo],
$token
);
return $data['repository']['id'] ?? '';
}
private function getOrgNodeId(string $org, string $token): string
{
$data = $this->graphql(
'query($login: String!) { organization(login: $login) { id } }',
['login' => $org],
$token
);
return $data['organization']['id'] ?? '';
}
/** @return array{bool, string} */
private function repoHasProject(string $org, string $repo, string $token): array
{
$data = $this->graphql(
'query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
projectsV2(first: 1) { nodes { id title } totalCount }
}
}',
['owner' => $org, 'name' => $repo],
$token
);
$count = $data['repository']['projectsV2']['totalCount'] ?? 0;
$title = $data['repository']['projectsV2']['nodes'][0]['title'] ?? '';
return [$count > 0, $title];
}
/** @return array{name: string, fields: array, views: array} */
private function parseTemplate(string $filePath): array
{
if (!file_exists($filePath)) {
return ['name' => 'Development Board', 'fields' => [], 'views' => []];
}
$content = file_get_contents($filePath);
$result = ['name' => 'Development Board', 'fields' => [], 'views' => []];
if (preg_match('/name\s*=\s*"([^"]+)"/', $content, $m)) {
$result['name'] = $m[1];
}
$fieldPattern = '/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"'
. '\s*description\s*=\s*"([^"]+)"'
. '(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s';
if (preg_match_all($fieldPattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$field = [
'name' => $match[1],
'type' => $match[2],
'description' => $match[3],
];
if (!empty($match[4])) {
$field['options'] = array_map(
fn($o) => trim($o, " \t\n\r\"'"),
explode(',', $match[4])
);
$field['options'] = array_filter($field['options']);
}
$result['fields'][] = $field;
}
}
return $result;
}
private function createProject(
string $org,
string $repo,
string $ownerId,
string $repoId,
array $template,
string $token
): bool {
$title = "{$repo} -- {$template['name']}";
if ($this->dryRun) {
echo " (dry-run) would create project: {$title}\n";
echo " (dry-run) fields: " . count($template['fields']) . "\n";
return true;
}
echo " Creating project: {$title}\n";
$data = $this->graphql(
'mutation($ownerId: ID!, $title: String!) {
createProjectV2(input: { ownerId: $ownerId, title: $title }) {
projectV2 { id number url }
}
}',
['ownerId' => $ownerId, 'title' => $title],
$token
);
$projectId = $data['createProjectV2']['projectV2']['id'] ?? '';
$projectUrl = $data['createProjectV2']['projectV2']['url'] ?? '';
if (empty($projectId)) {
$this->log('ERROR', " Failed to create project for {$repo}");
return false;
}
echo " Project created: {$projectUrl}\n";
$this->graphql(
'mutation($projectId: ID!, $repositoryId: ID!) {
linkProjectV2ToRepository(input: { projectId: $projectId, repositoryId: $repositoryId }) {
repository { id }
}
}',
['projectId' => $projectId, 'repositoryId' => $repoId],
$token
);
echo " Linked to {$org}/{$repo}\n";
$fieldCount = 0;
foreach ($template['fields'] as $field) {
$fieldType = match ($field['type']) {
'single_select' => 'SINGLE_SELECT',
'text' => 'TEXT',
'number' => 'NUMBER',
'date' => 'DATE',
'iteration' => 'ITERATION',
default => 'TEXT',
};
$vars = [
'projectId' => $projectId,
'name' => $field['name'],
'dataType' => $fieldType,
];
if ($fieldType === 'SINGLE_SELECT' && !empty($field['options'])) {
$optionInputs = array_map(
fn($o) => ['name' => $o, 'description' => '', 'color' => 'GRAY'],
$field['options']
);
$vars['singleSelectOptions'] = $optionInputs;
$this->graphql(
'mutation($projectId: ID!, $name: String!,'
. ' $dataType: ProjectV2CustomFieldType!,'
. ' $singleSelectOptions:'
. ' [ProjectV2SingleSelectFieldOptionInput!]) {
createProjectV2Field(input: {
projectId: $projectId,
dataType: $dataType,
name: $name,
singleSelectOptions: $singleSelectOptions
}) {
projectV2Field { ... on ProjectV2SingleSelectField { id name } }
}
}',
$vars,
$token
);
} else {
$this->graphql(
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {
createProjectV2Field(input: {
projectId: $projectId,
dataType: $dataType,
name: $name
}) {
projectV2Field { ... on ProjectV2Field { id name } }
}
}',
$vars,
$token
);
}
$fieldCount++;
}
echo " Created {$fieldCount} custom fields\n";
$this->graphql(
'mutation($projectId: ID!, $shortDescription: String!) {
updateProjectV2(input: {
projectId: $projectId,
shortDescription: $shortDescription,
readme: "Managed by mokocli. Run `php cli/create_project.php` to regenerate."
}) {
projectV2 { id }
}
}',
[
'projectId' => $projectId,
'shortDescription' => "Standard project board for {$repo}. Auto-created by mokocli.",
],
$token
);
echo " Project setup complete\n";
return true;
}
} }
/** $app = new CreateProjectCli();
* Execute a REST API GET call via the platform adapter's ApiClient. exit($app->execute());
*
* @return array<string, mixed>
*/
function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): array
{
if ($apiClient !== null) {
try {
return $apiClient->get("/{$path}");
} catch (\Exception $e) {
return [];
}
}
return [];
}
/**
* Detect platform type from .mokostandards file in the repo.
*/
function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
{
// Try platform metadata dir first, then root
foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) {
$data = restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient);
if (!empty($data['content'])) {
$content = base64_decode($data['content']);
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
return trim($m[1], " \t\n\r\"'");
}
}
}
return '';
}
/**
* Get the GitHub node ID for a repository.
*/
function getRepoNodeId(string $org, string $repo, string $token): string
{
$data = graphql(
'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }',
['owner' => $org, 'name' => $repo],
$token
);
return $data['repository']['id'] ?? '';
}
/**
* Get the GitHub node ID for the organization owner.
*/
function getOrgNodeId(string $org, string $token): string
{
$data = graphql(
'query($login: String!) { organization(login: $login) { id } }',
['login' => $org],
$token
);
return $data['organization']['id'] ?? '';
}
/**
* Check if a repo already has a GitHub Project linked.
*
* @return array{bool, string} [hasProject, projectTitle]
*/
function repoHasProject(string $org, string $repo, string $token): array
{
$data = graphql(
'query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
projectsV2(first: 1) { nodes { id title } totalCount }
}
}',
['owner' => $org, 'name' => $repo],
$token
);
$count = $data['repository']['projectsV2']['totalCount'] ?? 0;
$title = $data['repository']['projectsV2']['nodes'][0]['title'] ?? '';
return [$count > 0, $title];
}
/**
* Parse a .tf template file to extract custom fields.
*
* @return array{name: string, fields: array, views: array}
*/
function parseTemplate(string $filePath): array
{
if (!file_exists($filePath)) {
return ['name' => 'Development Board', 'fields' => [], 'views' => []];
}
$content = file_get_contents($filePath);
$result = ['name' => 'Development Board', 'fields' => [], 'views' => []];
// Extract project name
if (preg_match('/name\s*=\s*"([^"]+)"/', $content, $m)) {
$result['name'] = $m[1];
}
// Extract custom fields
if (preg_match_all('/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"\s*description\s*=\s*"([^"]+)"(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s', $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$field = [
'name' => $match[1],
'type' => $match[2],
'description' => $match[3],
];
if (!empty($match[4])) {
$field['options'] = array_map(
fn($o) => trim($o, " \t\n\r\"'"),
explode(',', $match[4])
);
$field['options'] = array_filter($field['options']);
}
$result['fields'][] = $field;
}
}
return $result;
}
/**
* Create a GitHub Project V2 for a repository.
*/
function createProject(
string $org,
string $repo,
string $ownerId,
string $repoId,
array $template,
string $token,
bool $dryRun
): bool {
$title = "{$repo}{$template['name']}";
if ($dryRun) {
echo " (dry-run) would create project: {$title}\n";
echo " (dry-run) fields: " . count($template['fields']) . "\n";
return true;
}
// Step 1: Create the project
echo " Creating project: {$title}\n";
$data = graphql(
'mutation($ownerId: ID!, $title: String!) {
createProjectV2(input: { ownerId: $ownerId, title: $title }) {
projectV2 { id number url }
}
}',
['ownerId' => $ownerId, 'title' => $title],
$token
);
$projectId = $data['createProjectV2']['projectV2']['id'] ?? '';
$projectUrl = $data['createProjectV2']['projectV2']['url'] ?? '';
if (empty($projectId)) {
fwrite(STDERR, " Failed to create project for {$repo}\n");
return false;
}
echo " Project created: {$projectUrl}\n";
// Step 2: Link the project to the repository
graphql(
'mutation($projectId: ID!, $repositoryId: ID!) {
linkProjectV2ToRepository(input: { projectId: $projectId, repositoryId: $repositoryId }) {
repository { id }
}
}',
['projectId' => $projectId, 'repositoryId' => $repoId],
$token
);
echo " Linked to {$org}/{$repo}\n";
// Step 3: Create custom fields
$fieldCount = 0;
foreach ($template['fields'] as $field) {
$fieldType = match ($field['type']) {
'single_select' => 'SINGLE_SELECT',
'text' => 'TEXT',
'number' => 'NUMBER',
'date' => 'DATE',
'iteration' => 'ITERATION',
default => 'TEXT',
};
$vars = [
'projectId' => $projectId,
'name' => $field['name'],
'dataType' => $fieldType,
];
// Single select fields need options created with the field
if ($fieldType === 'SINGLE_SELECT' && !empty($field['options'])) {
$optionInputs = array_map(
fn($o) => ['name' => $o, 'description' => '', 'color' => 'GRAY'],
$field['options']
);
$vars['singleSelectOptions'] = $optionInputs;
graphql(
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) {
createProjectV2Field(input: {
projectId: $projectId,
dataType: $dataType,
name: $name,
singleSelectOptions: $singleSelectOptions
}) {
projectV2Field { ... on ProjectV2SingleSelectField { id name } }
}
}',
$vars,
$token
);
} else {
graphql(
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {
createProjectV2Field(input: {
projectId: $projectId,
dataType: $dataType,
name: $name
}) {
projectV2Field { ... on ProjectV2Field { id name } }
}
}',
$vars,
$token
);
}
$fieldCount++;
}
echo " Created {$fieldCount} custom fields\n";
// Step 4: Update project description and README
graphql(
'mutation($projectId: ID!, $shortDescription: String!) {
updateProjectV2(input: {
projectId: $projectId,
shortDescription: $shortDescription,
readme: "Managed by MokoStandards. Run `php cli/create_project.php` to regenerate."
}) {
projectV2 { id }
}
}',
[
'projectId' => $projectId,
'shortDescription' => "Standard project board for {$repo}. Auto-created by MokoStandards.",
],
$token
);
echo " Project setup complete\n";
return true;
}
// ── Main ────────────────────────────────────────────────────────────────
$repos = [];
if ($allMode) {
echo "Fetching repositories from {$org}...\n";
$page = 1;
do {
$batch = restGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token);
foreach ($batch as $r) {
if (!$r['archived'] && !in_array($r['name'], $ALWAYS_EXCLUDE, true)) {
$repos[] = $r['name'];
}
}
$page++;
} while (count($batch) === 100);
sort($repos);
echo "Found " . count($repos) . " repositories\n\n";
} else {
$repos = [$repoName];
}
$ownerId = getOrgNodeId($org, $token);
if (empty($ownerId)) {
fwrite(STDERR, "Could not resolve org node ID for {$org}\n");
exit(1);
}
$created = 0;
$skipped = 0;
$failed = 0;
foreach ($repos as $repo) {
echo "Processing {$repo}...\n";
// Check if project already exists
[$hasProject, $existingTitle] = repoHasProject($org, $repo, $token);
if ($hasProject) {
echo " Already has project: {$existingTitle} — skipping\n";
$skipped++;
continue;
}
// Detect project type
$type = $typeOverride;
if (!$type) {
$platform = detectRepoPlatform($org, $repo, $token);
$type = $PLATFORM_TO_TYPE[$platform] ?? 'generic';
echo " Platform: {$platform} → type: {$type}\n";
}
// Load template
$templateFile = $TYPE_TO_TEMPLATE[$type] ?? $TYPE_TO_TEMPLATE['generic'];
$template = parseTemplate("{$templatesDir}/{$templateFile}");
// Get repo node ID
$repoId = getRepoNodeId($org, $repo, $token);
if (empty($repoId)) {
fwrite(STDERR, " Could not resolve repo node ID for {$repo}\n");
$failed++;
continue;
}
// Create the project
$ok = createProject($org, $repo, $ownerId, $repoId, $template, $token, $dryRun);
if ($ok) {
$created++;
} else {
$failed++;
}
echo "\n";
}
echo str_repeat('-', 50) . "\n";
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
exit($failed > 0 ? 1 : 0);
+206 -232
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,247 +8,220 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/create_repo.php * PATH: /cli/create_repo.php
* BRIEF: Scaffold a new governed repository with full MokoStandards baseline * BRIEF: Scaffold a new governed repository with full mokocli baseline
*
* USAGE
* php cli/create_repo.php --name MokoNewModule --type dolibarr --description "My new module"
* php cli/create_repo.php --name MokoNewModule --type joomla --private
* php cli/create_repo.php --name MokoNewModule --type generic --dry-run
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\Config; use MokoCli\CliFramework;
use MokoEnterprise\PlatformAdapterFactory; use MokoCli\Config;
use MokoCli\PlatformAdapterFactory;
$dryRun = in_array('--dry-run', $argv); class CreateRepoCli extends CliFramework
$private = in_array('--private', $argv); {
protected function configure(): void
{
$this->setDescription('Scaffold a new governed repository with full mokocli baseline');
$this->addArgument('--name', 'Repository name', null);
$this->addArgument('--type', 'Project type', null);
$this->addArgument('--description', 'Repository description', '');
$this->addArgument('--private', 'Create as private', false);
}
$name = null; protected function run(): int
$type = null; {
$description = ''; $name = $this->getArgument('--name');
$type = $this->getArgument('--type');
$description = $this->getArgument('--description');
$private = (bool) $this->getArgument('--private');
if (!$name || !$type) {
$this->log('ERROR', "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]");
return 2;
}
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
$repoRoot = dirname(__DIR__, 2);
$TYPE_TO_PLATFORM = [
'dolibarr' => 'crm-module',
'dolibarr-platform' => 'crm-platform',
'joomla' => 'waas-component',
'nodejs' => 'nodejs',
'terraform' => 'terraform',
'python' => 'python',
'wordpress' => 'wordpress',
'generic' => 'generic',
];
$TYPE_TO_TOPICS = [
'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokocli'],
'joomla' => ['joomla', 'cms', 'php', 'mokocli'],
'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokocli'],
'terraform' => ['terraform', 'infrastructure', 'iac', 'mokocli'],
'python' => ['python', 'mokocli'],
'wordpress' => ['wordpress', 'php', 'cms', 'mokocli'],
'generic' => ['mokocli'],
];
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic';
$topics = $TYPE_TO_TOPICS[$type] ?? ['mokocli'];
$platformName = $adapter->getPlatformName();
$vis = $private ? 'private' : 'public';
echo "Scaffolding new repository: {$org}/{$name}"
. " (on {$platformName})\n"
. " Type: {$type} (platform: {$platform})\n"
. " Visibility: {$vis}\n";
if ($description) {
echo " Description: {$description}\n";
} echo "\n";
foreach ($argv as $i => $arg) { echo "Step 1: Creating repository...\n";
if ($arg === '--name' && isset($argv[$i + 1])) { $name = $argv[$i + 1]; } if (!$this->dryRun) {
if ($arg === '--type' && isset($argv[$i + 1])) { $type = $argv[$i + 1]; } try {
if ($arg === '--description' && isset($argv[$i + 1])) { $description = $argv[$i + 1]; } $data = $adapter->createOrgRepo($org, $name, [
'description' => $description ?: "Managed by mokocli ({$type})",
'private' => $private,
'has_issues' => true,
'has_projects' => true,
'has_wiki' => false,
'auto_init' => true,
'delete_branch_on_merge' => true,
'allow_squash_merge' => true,
'allow_merge_commit' => false,
'allow_rebase_merge' => false,
]);
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n";
} catch (\Exception $e) {
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
echo " Repository already exists -- continuing with setup\n";
} else {
$this->log('ERROR', "Failed to create repo: " . $e->getMessage());
return 1;
}
}
} else {
echo " (dry-run) would create {$org}/{$name}\n";
}
echo "Step 2: Setting topics...\n";
if (!$this->dryRun) {
$adapter->setRepoTopics($org, $name, $topics);
echo " Topics: " . implode(', ', $topics) . "\n";
} else {
echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n";
}
echo "Step 3: Creating .mokogitea/manifest.xml...\n";
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n";
if (!$this->dryRun) {
try {
$adapter->createOrUpdateFile(
$org,
$name,
'.mokogitea/manifest.xml',
$mokoContent,
'chore: add manifest.xml platform config [skip ci]'
);
echo " manifest.xml created\n";
} catch (\Exception $e) {
echo " Warning: " . $e->getMessage() . "\n";
}
} else {
echo " (dry-run) would create .mokogitea/manifest.xml\n";
}
echo "Step 4: Creating README.md...\n";
$baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com';
$repoUrl = "{$baseUrl}/{$org}/{$name}";
$standardsUrl = "{$baseUrl}/{$org}/MokoCli";
$readmeContent = "<!--\n"
. "Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\n"
. "SPDX-License-Identifier: GPL-3.0-or-later\n"
. "DEFGROUP: {$name}\n"
. "INGROUP: mokocli\n"
. "REPO: {$repoUrl}\n"
. "PATH: /README.md\n"
. "BRIEF: {$description}\n"
. "-->\n\n"
. "# {$name}\n\n"
. "{$description}\n\n"
. "## Getting Started\n\n"
. "This repository is governed by"
. " [mokocli]({$standardsUrl}).\n\n"
. "## License\n\n"
. "GPL-3.0-or-later. See [LICENSE](LICENSE)"
. " for details.\n";
if (!$this->dryRun) {
$sha = null;
try {
$existing = $adapter->getFileContents($org, $name, 'README.md');
$sha = $existing['sha'] ?? null;
} catch (\Exception $e) {
$adapter->getApiClient()->resetCircuitBreaker();
}
$adapter->createOrUpdateFile(
$org,
$name,
'README.md',
$readmeContent,
'docs: initialize README with mokocli header [skip ci]',
$sha
);
echo " README.md created\n";
} else {
echo " (dry-run) would create README.md\n";
}
echo "Step 5: Provisioning labels...\n";
if (!$this->dryRun) {
$labelScript = "{$repoRoot}/api/maintenance/setup_labels.php";
if (file_exists($labelScript)) {
$exitCode = 0;
passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode);
} else {
echo " Labels will be provisioned on next sync\n";
}
} else {
echo " (dry-run) would provision standard labels\n";
}
echo "Step 6: Running initial sync...\n";
if (!$this->dryRun) {
$syncScript = "{$repoRoot}/api/automation/bulk_sync.php";
if (file_exists($syncScript)) {
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
} else {
echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n";
}
} else {
echo " (dry-run) would run initial sync\n";
}
echo "Step 7: Creating Project...\n";
if (!$this->dryRun) {
$projectScript = "{$repoRoot}/api/cli/create_project.php";
if (file_exists($projectScript)) {
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
} else {
echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n";
}
} else {
echo " (dry-run) would create Project\n";
}
echo "\n" . str_repeat('-', 50) . "\n"
. "Repository {$org}/{$name} scaffolded successfully\n"
. " URL: {$repoUrl}\n"
. " Platform: {$platform} ({$platformName})\n"
. " Next: verify the sync and merge any PRs\n";
return 0;
}
} }
if (!$name || !$type) { $app = new CreateRepoCli();
fwrite(STDERR, "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]\n"); exit($app->execute());
fwrite(STDERR, "\nTypes: generic, dolibarr, dolibarr-platform, joomla, nodejs, terraform, python, wordpress\n");
exit(2);
}
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $config->getString(
$adapter->getPlatformName() . '.organization',
'mokoconsulting-tech'
);
$repoRoot = dirname(__DIR__, 2);
$TYPE_TO_PLATFORM = [
'dolibarr' => 'crm-module',
'dolibarr-platform' => 'crm-platform',
'joomla' => 'waas-component',
'nodejs' => 'nodejs',
'terraform' => 'terraform',
'python' => 'python',
'wordpress' => 'wordpress',
'generic' => 'generic',
];
$TYPE_TO_TOPICS = [
'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'],
'joomla' => ['joomla', 'cms', 'php', 'mokostandards'],
'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'],
'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'],
'python' => ['python', 'mokostandards'],
'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'],
'generic' => ['mokostandards'],
];
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic';
$topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards'];
$platformName = $adapter->getPlatformName();
echo "Scaffolding new repository: {$org}/{$name} (on {$platformName})\n";
echo " Type: {$type} (platform: {$platform})\n";
echo " Visibility: " . ($private ? 'private' : 'public') . "\n";
if ($description) { echo " Description: {$description}\n"; }
echo "\n";
// ── Step 1: Create the repository ───────────────────────────────────────
echo "Step 1: Creating repository...\n";
if (!$dryRun) {
try {
$data = $adapter->createOrgRepo($org, $name, [
'description' => $description ?: "Managed by MokoStandards ({$type})",
'private' => $private,
'has_issues' => true,
'has_projects' => true,
'has_wiki' => false,
'auto_init' => true,
'delete_branch_on_merge' => true,
'allow_squash_merge' => true,
'allow_merge_commit' => false,
'allow_rebase_merge' => false,
]);
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n";
} catch (\Exception $e) {
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
echo " Repository already exists — continuing with setup\n";
} else {
fwrite(STDERR, " Failed to create repo: " . $e->getMessage() . "\n");
exit(1);
}
}
} else {
echo " (dry-run) would create {$org}/{$name}\n";
}
// ── Step 2: Set topics ──────────────────────────────────────────────────
echo "Step 2: Setting topics...\n";
if (!$dryRun) {
$adapter->setRepoTopics($org, $name, $topics);
echo " Topics: " . implode(', ', $topics) . "\n";
} else {
echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n";
}
// ── Step 3: Create .mokostandards file ──────────────────────────────────
echo "Step 3: Creating .github/.mokostandards...\n";
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n";
if (!$dryRun) {
try {
$adapter->createOrUpdateFile(
$org, $name, '.github/.mokostandards', $mokoContent,
'chore: add .mokostandards platform config [skip ci]'
);
echo " .mokostandards created\n";
} catch (\Exception $e) {
echo " Warning: " . $e->getMessage() . "\n";
}
} else {
echo " (dry-run) would create .github/.mokostandards\n";
}
// ── Step 4: Create initial README.md ────────────────────────────────────
echo "Step 4: Creating README.md...\n";
// Determine the repo base URL based on platform
$baseUrl = $platformName === 'gitea'
? $config->getString('gitea.url', 'https://git.mokoconsulting.tech')
: 'https://github.com';
$repoUrl = "{$baseUrl}/{$org}/{$name}";
$standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
$readmeContent = <<<MD
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
DEFGROUP: {$name}
INGROUP: moko-platform
REPO: {$repoUrl}
PATH: /README.md
BRIEF: {$description}
-->
# {$name}
[![MokoStandards](https://img.shields.io/badge/MokoStandards-04.06.00-blue)]({$standardsUrl})
[![Version](https://img.shields.io/badge/version-01.00.00-green)]({$repoUrl})
{$description}
## Getting Started
This repository is governed by [MokoStandards]({$standardsUrl}).
## License
This project is licensed under the GPL-3.0-or-later license. See [LICENSE](LICENSE) for details.
---
*This file is part of the Moko Consulting ecosystem. All rights reserved.*
*This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.*
MD;
if (!$dryRun) {
// Get existing README sha (auto_init creates one)
$sha = null;
try {
$existing = $adapter->getFileContents($org, $name, 'README.md');
$sha = $existing['sha'] ?? null;
} catch (\Exception $e) {
$adapter->getApiClient()->resetCircuitBreaker();
}
$adapter->createOrUpdateFile(
$org, $name, 'README.md', $readmeContent,
'docs: initialize README with MokoStandards header [skip ci]',
$sha
);
echo " README.md created\n";
} else {
echo " (dry-run) would create README.md\n";
}
// ── Step 5: Provision labels ────────────────────────────────────────────
echo "Step 5: Provisioning labels...\n";
if (!$dryRun) {
$labelScript = "{$repoRoot}/api/maintenance/setup_labels.php";
if (file_exists($labelScript)) {
$exitCode = 0;
passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode);
echo $exitCode === 0 ? " Labels provisioned\n" : " Label provisioning had issues\n";
} else {
echo " Labels will be provisioned on next sync\n";
}
} else {
echo " (dry-run) would provision standard labels\n";
}
// ── Step 6: Run first sync ──────────────────────────────────────────────
echo "Step 6: Running initial sync...\n";
if (!$dryRun) {
$syncScript = "{$repoRoot}/api/automation/bulk_sync.php";
if (file_exists($syncScript)) {
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
} else {
echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n";
}
} else {
echo " (dry-run) would run initial sync\n";
}
// ── Step 7: Create Project ──────────────────────────────────────────────
echo "Step 7: Creating Project...\n";
if (!$dryRun) {
$projectScript = "{$repoRoot}/api/cli/create_project.php";
if (file_exists($projectScript)) {
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
} else {
echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n";
}
} else {
echo " (dry-run) would create Project\n";
}
echo "\n" . str_repeat('-', 50) . "\n";
echo "Repository {$org}/{$name} scaffolded successfully\n";
echo " URL: {$repoUrl}\n";
echo " Platform: {$platform} ({$platformName})\n";
echo " Next: verify the sync and merge any PRs\n";
File diff suppressed because it is too large Load Diff
+84 -81
View File
@@ -1,97 +1,100 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/dev_branch_reset.php * PATH: /cli/dev_branch_reset.php
* BRIEF: Delete and recreate dev branch from main via Gitea API * BRIEF: Delete and recreate dev branch from main via Gitea API
*
* Usage:
* php dev_branch_reset.php --token TOKEN --api-base URL
* php dev_branch_reset.php --token TOKEN --api-base URL --branch dev --from main
*
* Options:
* --token Gitea API token (required)
* --api-base Gitea API base URL (required)
* --branch Branch to reset (default: dev)
* --from Source branch (default: main)
* --output-summary Write to $GITHUB_STEP_SUMMARY
*/ */
declare(strict_types=1); declare(strict_types=1);
$token = null; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$apiBase = null;
$branch = 'dev';
$from = 'main';
$outputSummary = false;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1]; class DevBranchResetCli extends CliFramework
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1]; {
if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1]; protected function configure(): void
if ($arg === '--output-summary') $outputSummary = true; {
$this->setDescription('Delete and recreate dev branch from main via Gitea API');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--api-base', 'Gitea API base URL', '');
$this->addArgument('--branch', 'Branch to reset', 'dev');
$this->addArgument('--from', 'Source branch', 'main');
$this->addArgument('--output-summary', 'Write to $GITHUB_STEP_SUMMARY', false);
}
protected function run(): int
{
$token = $this->getArgument('--token') ?: getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: '';
$apiBase = $this->getArgument('--api-base');
$branch = $this->getArgument('--branch');
$from = $this->getArgument('--from');
$outputSummary = $this->getArgument('--output-summary');
if (empty($token) || empty($apiBase)) {
$this->log('ERROR', 'Usage: dev_branch_reset.php --token TOKEN --api-base URL [--branch dev] [--from main]');
return 1;
}
// Delete branch (tolerate 404)
$ch = curl_init("{$apiBase}/branches/{$branch}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ch);
$delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($delCode === 204 || $delCode === 200) {
$this->log('INFO', "Deleted branch '{$branch}'");
} elseif ($delCode === 404) {
$this->log('INFO', "Branch '{$branch}' did not exist (skipped delete)");
} else {
$this->warning("Delete branch returned HTTP {$delCode}");
}
// Create branch from source
$payload = json_encode(['new_branch_name' => $branch, 'old_branch_name' => $from]);
$ch = curl_init("{$apiBase}/branches");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$createCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($createCode === 201) {
$this->success("Recreated '{$branch}' from '{$from}'");
} else {
$this->log('ERROR', "Failed to create branch '{$branch}' from '{$from}' (HTTP {$createCode})");
return 1;
}
if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) {
file_put_contents($summaryFile, "Dev branch reset: '{$branch}' recreated from '{$from}'\n", FILE_APPEND);
}
}
return 0;
}
} }
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; $app = new DevBranchResetCli();
exit($app->execute());
if ($token === null || $apiBase === null) {
fwrite(STDERR, "Usage: dev_branch_reset.php --token TOKEN --api-base URL [--branch dev] [--from main]\n");
exit(1);
}
// Delete branch (tolerate 404)
$ch = curl_init("{$apiBase}/branches/{$branch}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ch);
$delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($delCode === 204 || $delCode === 200) {
echo "Deleted branch '{$branch}'\n";
} elseif ($delCode === 404) {
echo "Branch '{$branch}' did not exist (skipped delete)\n";
} else {
fwrite(STDERR, "WARNING: Delete branch returned HTTP {$delCode}\n");
}
// Create branch from source
$payload = json_encode(['new_branch_name' => $branch, 'old_branch_name' => $from]);
$ch = curl_init("{$apiBase}/branches");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$createCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($createCode === 201) {
echo "Recreated '{$branch}' from '{$from}'\n";
} else {
fwrite(STDERR, "Failed to create branch '{$branch}' from '{$from}' (HTTP {$createCode})\n");
exit(1);
}
if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) {
file_put_contents($summaryFile, "Dev branch reset: '{$branch}' recreated from '{$from}'\n", FILE_APPEND);
}
}
exit(0);
+81 -178
View File
@@ -8,17 +8,21 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/grafana_dashboard.php * PATH: /cli/grafana_dashboard.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Manage Grafana dashboards via API * BRIEF: Manage Grafana dashboards via API
*/ */
declare(strict_types=1); declare(strict_types=1);
final class GrafanaDashboard require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class GrafanaDashboardCli extends CliFramework
{ {
private string $grafanaUrl = ''; private string $grafanaUrl = '';
private string $token = ''; private string $token = '';
@@ -29,24 +33,52 @@ final class GrafanaDashboard
private string $folderTitle = ''; private string $folderTitle = '';
private bool $overwrite = true; private bool $overwrite = true;
public function run(): int protected function configure(): void
{ {
$this->parseArgs(); $this->setDescription('Manage Grafana dashboards via API');
$this->addArgument('--url', 'Grafana URL (or GRAFANA_URL)', '');
$this->addArgument('--token', 'API token (or GRAFANA_TOKEN)', '');
$this->addArgument('--uid', 'Dashboard UID (delete/export)', '');
$this->addArgument('--file', 'JSON file (push/export)', '');
$this->addArgument('--folder', 'Folder name (push/list)', '');
$this->addArgument('--folder-id', 'Folder ID (push/list)', '0');
$this->addArgument('--no-overwrite', 'Fail if dashboard exists', false);
$this->addArgument('--command', 'Command: push, delete, list, export', '');
}
protected function run(): int
{
// Parse positional command from raw argv
$rawArgs = $_SERVER['argv'] ?? [];
foreach ($rawArgs as $arg) {
if (in_array($arg, ['push', 'delete', 'list', 'export'], true)) {
$this->command = $arg;
break;
}
}
if ($this->command === '' && $this->getArgument('--command') !== '') {
$this->command = $this->getArgument('--command');
}
$this->grafanaUrl = $this->getArgument('--url');
$this->token = $this->getArgument('--token');
$this->uid = $this->getArgument('--uid');
$this->file = $this->getArgument('--file');
$this->folderTitle = $this->getArgument('--folder');
$this->folderId = (int) $this->getArgument('--folder-id');
$this->overwrite = !$this->getArgument('--no-overwrite');
if ($this->grafanaUrl === '') { if ($this->grafanaUrl === '') {
$this->grafanaUrl = getenv('GRAFANA_URL') ?: ''; $this->grafanaUrl = getenv('GRAFANA_URL') ?: '';
} }
$this->grafanaUrl = rtrim($this->grafanaUrl, '/');
if ($this->token === '') { if ($this->token === '') {
$this->token = getenv('GRAFANA_TOKEN') ?: ''; $this->token = getenv('GRAFANA_TOKEN') ?: '';
} }
if ($this->grafanaUrl === '' || $this->token === '') { if ($this->grafanaUrl === '' || $this->token === '') {
$this->log( $this->log('ERROR', '--url and --token are required (or set GRAFANA_URL / GRAFANA_TOKEN env vars).');
'ERROR: --url and --token are required '
. '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).'
);
$this->printUsage();
return 1; return 1;
} }
@@ -62,12 +94,12 @@ final class GrafanaDashboard
private function pushDashboard(): int private function pushDashboard(): int
{ {
if ($this->file === '') { if ($this->file === '') {
$this->log('ERROR: --file is required for push.'); $this->log('ERROR', '--file is required for push.');
return 1; return 1;
} }
if (!file_exists($this->file)) { if (!file_exists($this->file)) {
$this->log("ERROR: File not found: {$this->file}"); $this->log('ERROR', "File not found: {$this->file}");
return 1; return 1;
} }
@@ -75,14 +107,12 @@ final class GrafanaDashboard
$dashboard = json_decode($json, true); $dashboard = json_decode($json, true);
if (!is_array($dashboard)) { if (!is_array($dashboard)) {
$this->log('ERROR: Invalid JSON in dashboard file.'); $this->log('ERROR', 'Invalid JSON in dashboard file.');
return 1; return 1;
} }
if ($this->folderTitle !== '' && $this->folderId === 0) { if ($this->folderTitle !== '' && $this->folderId === 0) {
$this->folderId = $this->resolveFolderId( $this->folderId = $this->resolveFolderId($this->folderTitle);
$this->folderTitle
);
if ($this->folderId < 0) { if ($this->folderId < 0) {
return 1; return 1;
@@ -97,29 +127,23 @@ final class GrafanaDashboard
'overwrite' => $this->overwrite, 'overwrite' => $this->overwrite,
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest('POST', '/api/dashboards/db', $payload);
'POST',
'/api/dashboards/db',
$payload
);
if ($response['code'] === 200) { if ($response['code'] === 200) {
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
$uid = $data['uid'] ?? '?'; $uid = $data['uid'] ?? '?';
$url = $data['url'] ?? ''; $url = $data['url'] ?? '';
$status = $data['status'] ?? 'success'; $status = $data['status'] ?? 'success';
$this->log("OK: {$status} (uid: {$uid})"); $this->log('INFO', "OK: {$status} (uid: {$uid})");
if ($url !== '') { if ($url !== '') {
$this->log("URL: {$this->grafanaUrl}{$url}"); $this->log('INFO', "URL: {$this->grafanaUrl}{$url}");
} }
return 0; return 0;
} }
$this->log( $this->log('ERROR', "Push failed (HTTP {$response['code']})");
"ERROR: Push failed (HTTP {$response['code']})"
);
$this->logApiError($response['body']); $this->logApiError($response['body']);
return 1; return 1;
@@ -128,30 +152,23 @@ final class GrafanaDashboard
private function deleteDashboard(): int private function deleteDashboard(): int
{ {
if ($this->uid === '') { if ($this->uid === '') {
$this->log('ERROR: --uid is required for delete.'); $this->log('ERROR', '--uid is required for delete.');
return 1; return 1;
} }
$response = $this->apiRequest( $response = $this->apiRequest('DELETE', "/api/dashboards/uid/{$this->uid}");
'DELETE',
"/api/dashboards/uid/{$this->uid}"
);
if ($response['code'] === 200) { if ($response['code'] === 200) {
$this->log("OK: Deleted dashboard {$this->uid}"); $this->log('INFO', "OK: Deleted dashboard {$this->uid}");
return 0; return 0;
} }
if ($response['code'] === 404) { if ($response['code'] === 404) {
$this->log( $this->warning("Dashboard {$this->uid} not found.");
"WARN: Dashboard {$this->uid} not found."
);
return 0; return 0;
} }
$this->log( $this->log('ERROR', "Delete failed (HTTP {$response['code']})");
"ERROR: Delete failed (HTTP {$response['code']})"
);
$this->logApiError($response['body']); $this->logApiError($response['body']);
return 1; return 1;
@@ -176,42 +193,33 @@ final class GrafanaDashboard
$response = $this->apiRequest('GET', $query); $response = $this->apiRequest('GET', $query);
if ($response['code'] !== 200) { if ($response['code'] !== 200) {
$this->log( $this->log('ERROR', "List failed (HTTP {$response['code']})");
"ERROR: List failed (HTTP {$response['code']})"
);
$this->logApiError($response['body']); $this->logApiError($response['body']);
return 1; return 1;
} }
$dashboards = json_decode($response['body'], true); $dashboards = json_decode($response['body'], true);
if ( if (!is_array($dashboards) || count($dashboards) === 0) {
!is_array($dashboards) $this->log('INFO', 'No dashboards found.');
|| count($dashboards) === 0
) {
$this->log('No dashboards found.');
return 0; return 0;
} }
$this->log(sprintf( fprintf(STDERR, "%-30s | %-20s | %s\n", 'Title', 'UID', 'Folder');
'%-30s | %-20s | %s', fprintf(STDERR, "%s\n", str_repeat('-', 75));
'Title',
'UID',
'Folder'
));
$this->log(str_repeat('-', 75));
foreach ($dashboards as $d) { foreach ($dashboards as $d) {
$this->log(sprintf( fprintf(
'%-30s | %-20s | %s', STDERR,
"%-30s | %-20s | %s\n",
substr($d['title'] ?? '', 0, 30), substr($d['title'] ?? '', 0, 30),
$d['uid'] ?? '', $d['uid'] ?? '',
$d['folderTitle'] ?? 'General' $d['folderTitle'] ?? 'General'
)); );
} }
$this->log(''); echo "\n";
$this->log(count($dashboards) . ' dashboard(s).'); $this->log('INFO', count($dashboards) . ' dashboard(s).');
return 0; return 0;
} }
@@ -219,20 +227,14 @@ final class GrafanaDashboard
private function exportDashboard(): int private function exportDashboard(): int
{ {
if ($this->uid === '') { if ($this->uid === '') {
$this->log('ERROR: --uid is required for export.'); $this->log('ERROR', '--uid is required for export.');
return 1; return 1;
} }
$response = $this->apiRequest( $response = $this->apiRequest('GET', "/api/dashboards/uid/{$this->uid}");
'GET',
"/api/dashboards/uid/{$this->uid}"
);
if ($response['code'] !== 200) { if ($response['code'] !== 200) {
$this->log( $this->log('ERROR', "Export failed (HTTP {$response['code']})");
"ERROR: Export failed "
. "(HTTP {$response['code']})"
);
$this->logApiError($response['body']); $this->logApiError($response['body']);
return 1; return 1;
} }
@@ -241,9 +243,7 @@ final class GrafanaDashboard
$dashboard = $data['dashboard'] ?? null; $dashboard = $data['dashboard'] ?? null;
if ($dashboard === null) { if ($dashboard === null) {
$this->log( $this->log('ERROR', 'No dashboard data in response.');
'ERROR: No dashboard data in response.'
);
return 1; return 1;
} }
@@ -254,9 +254,7 @@ final class GrafanaDashboard
if ($this->file !== '') { if ($this->file !== '') {
file_put_contents($this->file, $output); file_put_contents($this->file, $output);
$this->log( $this->log('INFO', "Exported {$this->uid} to {$this->file}");
"Exported {$this->uid} to {$this->file}"
);
} else { } else {
fwrite(STDOUT, $output); fwrite(STDOUT, $output);
} }
@@ -269,10 +267,7 @@ final class GrafanaDashboard
$response = $this->apiRequest('GET', '/api/folders'); $response = $this->apiRequest('GET', '/api/folders');
if ($response['code'] !== 200) { if ($response['code'] !== 200) {
$this->log( $this->log('ERROR', "Could not fetch folders (HTTP {$response['code']})");
"ERROR: Could not fetch folders "
. "(HTTP {$response['code']})"
);
return -1; return -1;
} }
@@ -283,106 +278,22 @@ final class GrafanaDashboard
} }
foreach ($folders as $f) { foreach ($folders as $f) {
if ( if (strcasecmp($f['title'] ?? '', $title) === 0) {
strcasecmp(
$f['title'] ?? '',
$title
) === 0
) {
return (int) ($f['id'] ?? 0); return (int) ($f['id'] ?? 0);
} }
} }
$this->log( $this->warning("Folder \"{$title}\" not found, using General.");
"WARN: Folder \"{$title}\" not found, "
. "using General."
);
return 0; return 0;
} }
private function noCommand(): int private function noCommand(): int
{ {
$this->log('ERROR: No command specified.'); $this->log('ERROR', 'No command specified. Use: push, delete, list, export');
$this->printUsage();
return 1; return 1;
} }
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) {
case 'push':
case 'delete':
case 'list':
case 'export':
$this->command = $args[$i];
break;
case '--url':
$this->grafanaUrl = rtrim(
$args[++$i] ?? '',
'/'
);
break;
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--uid':
$this->uid = $args[++$i] ?? '';
break;
case '--file':
$this->file = $args[++$i] ?? '';
break;
case '--folder-id':
$this->folderId = (int) (
$args[++$i] ?? 0
);
break;
case '--folder':
$this->folderTitle = $args[++$i] ?? '';
break;
case '--no-overwrite':
$this->overwrite = false;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log(
"WARNING: Unknown arg: {$args[$i]}"
);
break;
}
}
}
private function printUsage(): void
{
$u = 'Usage: grafana_dashboard.php <command> '
. '--url <url> --token <token> [options]';
$this->log($u);
$this->log('');
$this->log('Commands:');
$this->log(' push Create/update dashboard from JSON');
$this->log(' delete Delete a dashboard by UID');
$this->log(' list List dashboards (optionally by folder)');
$this->log(' export Export dashboard JSON by UID');
$this->log('');
$this->log('Options:');
$this->log(' --url <url> Grafana URL (or GRAFANA_URL)');
$this->log(' --token <token> API token (or GRAFANA_TOKEN)');
$this->log(' --uid <uid> Dashboard UID (delete/export)');
$this->log(' --file <path> JSON file (push/export)');
$this->log(' --folder <name> Folder name (push/list)');
$this->log(' --folder-id <id> Folder ID (push/list)');
$this->log(' --no-overwrite Fail if dashboard exists');
$this->log(' --help, -h Show this help');
}
private function apiRequest( private function apiRequest(
string $method, string $method,
string $endpoint, string $endpoint,
@@ -405,10 +316,7 @@ final class GrafanaDashboard
} }
$responseBody = curl_exec($ch); $responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo( $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$ch,
CURLINFO_HTTP_CODE
);
if (curl_errno($ch)) { if (curl_errno($ch)) {
$error = curl_error($ch); $error = curl_error($ch);
@@ -430,15 +338,10 @@ final class GrafanaDashboard
$data = json_decode($body, true); $data = json_decode($body, true);
if (is_array($data) && isset($data['message'])) { if (is_array($data) && isset($data['message'])) {
$this->log(" Grafana: {$data['message']}"); $this->log('ERROR', " Grafana: {$data['message']}");
} }
} }
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new GrafanaDashboard(); $app = new GrafanaDashboardCli();
exit($app->run()); exit($app->execute());
+330 -288
View File
@@ -1,307 +1,349 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_build.php * PATH: /cli/joomla_build.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported * BRIEF: Build a Joomla extension ZIP from manifest — all types supported
* NOTE: Called by pre-release and auto-release workflows. * NOTE: Called by pre-release and auto-release workflows.
*
* USAGE
* php joomla_build.php --path . --version 02.01.24
* php joomla_build.php --path . --version 02.01.24 --suffix -dev
* php joomla_build.php --path . --version 02.01.24 --output build --github-output
*
* Supports: plugin, module, component, template, package, library, file
*/ */
declare(strict_types=1); declare(strict_types=1);
// ── Argument parsing ──────────────────────────────────────────────────── require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.';
$version = '';
$suffix = '';
$outputDir = 'build';
$ghOutput = false;
foreach ($argv as $i => $arg) { use MokoCli\{CliFramework, SourceResolver};
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
if ($arg === '--suffix' && isset($argv[$i + 1])) $suffix = $argv[$i + 1];
if ($arg === '--output' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
}
if ($version === '') { class JoomlaBuildCli extends CliFramework
fwrite(STDERR, "::error::--version is required\n");
exit(1);
}
$path = realpath($path) ?: $path;
// ── Find source directory ──────────────────────────────────────────────
$srcDir = null;
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; }
}
if ($srcDir === null) {
fwrite(STDERR, "::error::No src/ or htdocs/ directory in {$path}\n");
exit(1);
}
// ── Find manifest ──────────────────────────────────────────────────────
$manifest = findManifest($srcDir);
if ($manifest === null) {
fwrite(STDERR, "::error::No Joomla manifest found in {$srcDir}\n");
exit(1);
}
fwrite(STDERR, "Manifest: {$manifest}\n");
// ── Parse manifest ─────────────────────────────────────────────────────
$meta = parseManifest($manifest);
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
$resolved = resolveLanguageKey($srcDir, $meta['name']);
if ($resolved !== null) { $meta['name'] = $resolved; }
}
$prefix = typePrefix($meta);
$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip";
$zipPath = "{$outputDir}/{$zipName}";
fwrite(STDERR, "=== Joomla Build: {$meta['type']}{$meta['element']} {$version}{$suffix} ===\n");
fwrite(STDERR, " Type: {$meta['type']}\n");
fwrite(STDERR, " Element: {$meta['element']}\n");
fwrite(STDERR, " Group: " . ($meta['group'] ?: 'n/a') . "\n");
fwrite(STDERR, " Name: {$meta['name']}\n");
fwrite(STDERR, " Output: {$zipName}\n");
// ── Build ──────────────────────────────────────────────────────────────
if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); }
if ($meta['type'] === 'package') {
buildPackageZip($srcDir, $zipPath);
} else {
buildZip($srcDir, $zipPath);
}
$sha256 = hash_file('sha256', $zipPath);
$size = filesize($zipPath);
fwrite(STDERR, "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)\n");
// ── Output variables ───────────────────────────────────────────────────
$vars = [
'zip_name' => $zipName,
'zip_path' => $zipPath,
'sha256' => $sha256,
'ext_type' => $meta['type'],
'ext_element' => $meta['element'],
'ext_name' => $meta['name'],
'ext_group' => $meta['group'],
'type_prefix' => $prefix,
];
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
$fh = fopen($ghFile, 'a');
foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); }
fclose($fh);
fwrite(STDERR, "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT\n");
} else {
foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; }
}
exit(0);
// ═══════════════════════════════════════════════════════════════════════
// Functions
// ═══════════════════════════════════════════════════════════════════════
function findManifest(string $dir): ?string
{ {
// Priority: pkg_*.xml (packages), then any *.xml with <extension> protected function configure(): void
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; } {
foreach (glob("{$dir}/*.xml") ?: [] as $f) { $this->setDescription('Build a Joomla extension ZIP from manifest');
if (str_contains((string) file_get_contents($f), '<extension')) { return $f; } $this->addArgument('--path', 'Repository root path', '.');
} $this->addArgument('--version', 'Version string (required)', '');
// Broader nested search $this->addArgument('--suffix', 'Version suffix (e.g. -dev)', '');
$iter = new RecursiveIteratorIterator( $this->addArgument('--output', 'Output directory', 'build');
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), $this->addArgument('--github-output', 'Write outputs to GITHUB_OUTPUT file', false);
RecursiveIteratorIterator::SELF_FIRST }
);
foreach ($iter as $item) { protected function run(): int
if ($item->isFile() && $item->getExtension() === 'xml') { {
if (str_contains((string) file_get_contents($item->getPathname()), '<extension')) { $path = $this->getArgument('--path');
return $item->getPathname(); $version = $this->getArgument('--version');
} $suffix = $this->getArgument('--suffix');
} $outputDir = $this->getArgument('--output');
} $ghOutput = (bool) $this->getArgument('--github-output');
return null;
if ($version === '') {
$this->log('ERROR', '::error::--version is required');
return 1;
}
$path = realpath($path) ?: $path;
// ── Find source directory ──────────────────────────────────────────────
$srcDir = SourceResolver::resolveAbsolute($path);
if ($srcDir === null) {
$this->log('ERROR', "::error::No source/ or src/ directory in {$path}");
return 1;
}
SourceResolver::warnIfLegacy($path);
// ── Find manifest ──────────────────────────────────────────────────────
$manifest = $this->findManifest($srcDir);
if ($manifest === null) {
$this->log('ERROR', "::error::No Joomla manifest found in {$srcDir}");
return 1;
}
$this->log('INFO', "Manifest: {$manifest}");
// ── Parse manifest ─────────────────────────────────────────────────────
$meta = $this->parseManifest($manifest);
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
$resolved = $this->resolveLanguageKey($srcDir, $meta['name']);
if ($resolved !== null) {
$meta['name'] = $resolved;
}
}
$prefix = $this->typePrefix($meta);
$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip";
$zipPath = "{$outputDir}/{$zipName}";
$this->log('INFO', "=== Joomla Build: {$meta['type']}{$meta['element']} {$version}{$suffix} ===");
$this->log('INFO', " Type: {$meta['type']}");
$this->log('INFO', " Element: {$meta['element']}");
$this->log('INFO', " Group: " . ($meta['group'] ?: 'n/a'));
$this->log('INFO', " Name: {$meta['name']}");
$this->log('INFO', " Output: {$zipName}");
// ── Build ──────────────────────────────────────────────────────────────
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
if ($meta['type'] === 'package') {
$this->buildPackageZip($srcDir, $zipPath);
} else {
$this->buildZip($srcDir, $zipPath);
}
$sha256 = hash_file('sha256', $zipPath);
$size = filesize($zipPath);
$this->log('INFO', "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)");
// ── Output variables ───────────────────────────────────────────────────
$vars = [
'zip_name' => $zipName,
'zip_path' => $zipPath,
'sha256' => $sha256,
'ext_type' => $meta['type'],
'ext_element' => $meta['element'],
'ext_name' => $meta['name'],
'ext_group' => $meta['group'],
'type_prefix' => $prefix,
];
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
$fh = fopen($ghFile, 'a');
foreach ($vars as $k => $v) {
fwrite($fh, "{$k}={$v}\n");
}
fclose($fh);
$this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT");
} else {
foreach ($vars as $k => $v) {
echo "{$k}={$v}\n";
}
}
return 0;
}
// ═══════════════════════════════════════════════════════════════════════
// Private methods
// ═══════════════════════════════════════════════════════════════════════
private function findManifest(string $dir): ?string
{
// Priority: pkg_*.xml (packages), then any *.xml with <extension>
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) {
return $f;
}
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
if (str_contains((string) file_get_contents($f), '<extension')) {
return $f;
}
}
// Broader nested search
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $item) {
if ($item->isFile() && $item->getExtension() === 'xml') {
if (str_contains((string) file_get_contents($item->getPathname()), '<extension')) {
return $item->getPathname();
}
}
}
return null;
}
private function parseManifest(string $file): array
{
$xml = simplexml_load_file($file);
$name = (string) ($xml->name ?? '');
$type = (string) ($xml->attributes()->type ?? 'component');
$element = (string) ($xml->element ?? '');
$group = (string) ($xml->attributes()->group ?? '');
// For packages, prefer <packagename> as the clean element (avoids pkg_pkg_ duplication)
if ($type === 'package' && $element === '') {
$packageName = (string) ($xml->packagename ?? '');
if ($packageName !== '') {
$element = $packageName;
}
}
// Fallback element detection
if ($element === '') {
$element = (string) ($xml->attributes()->plugin ?? '');
}
if ($element === '') {
$element = (string) ($xml->attributes()->module ?? '');
}
if ($element === '') {
$element = strtolower(basename($file, '.xml'));
if (in_array($element, ['templatedetails', 'manifest'], true)) {
$element = strtolower(basename(dirname($file)));
}
}
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas -> mokowaas)
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
if ($name === '') {
$name = $element;
}
return compact('name', 'type', 'element', 'group');
}
private function typePrefix(array $meta): string
{
return match ($meta['type']) {
'plugin' => "plg_{$meta['group']}_",
'module' => 'mod_',
'component' => 'com_',
'template' => 'tpl_',
'package' => 'pkg_',
'library' => 'lib_',
default => '',
};
}
private function resolveLanguageKey(string $srcDir, string $key): ?string
{
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iter as $item) {
if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) {
foreach (file($item->getPathname()) as $line) {
if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) {
return $m[1];
}
}
}
}
return null;
}
private function isExcluded(string $name): bool
{
if ($name === '.ftpignore') {
return true;
}
if (str_starts_with($name, 'sftp-config')) {
return true;
}
if (str_starts_with($name, '.env')) {
return true;
}
if (str_starts_with($name, '.build-trigger')) {
return true;
}
$ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
}
private function buildZip(string $srcDir, string $outPath): void
{
$zip = new ZipArchive();
if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
$this->log('ERROR', "::error::Cannot create ZIP: {$outPath}");
return;
}
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $file) {
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
if ($this->isExcluded(basename($local))) {
continue;
}
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
}
$zip->close();
}
private function buildPackageZip(string $srcDir, string $outPath): void
{
$this->log('INFO', "Building Joomla package (multi-extension)...");
$staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid();
mkdir($staging, 0755, true);
// 1. Zip each sub-extension in packages/
$packagesDir = "{$srcDir}/packages";
if (is_dir($packagesDir)) {
foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) {
$subManifest = $this->findManifest($extDir);
if ($subManifest) {
$sub = $this->parseManifest($subManifest);
$subPrefix = $this->typePrefix($sub);
$subZipName = "{$subPrefix}{$sub['element']}.zip";
} else {
$subZipName = basename($extDir) . '.zip';
}
$this->log('INFO', " Sub-extension: {$subZipName}");
$this->buildZip($extDir, "{$staging}/{$subZipName}");
}
}
// 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (glob("{$srcDir}/*.php") ?: [] as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) {
$this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
}
}
// 3. Create outer zip
$this->buildZip($staging, $outPath);
// Cleanup
$this->rmTree($staging);
}
private function copyTree(string $src, string $dst): void
{
if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $item) {
$target = "{$dst}/" . $iter->getSubPathname();
$item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target);
}
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
} }
function parseManifest(string $file): array $app = new JoomlaBuildCli();
{ exit($app->execute());
$xml = simplexml_load_file($file);
$name = (string) ($xml->name ?? '');
$type = (string) ($xml->attributes()->type ?? 'component');
$element = (string) ($xml->element ?? '');
$group = (string) ($xml->attributes()->group ?? '');
// For packages, prefer <packagename> as the clean element (avoids pkg_pkg_ duplication)
if ($type === 'package' && $element === '') {
$packageName = (string) ($xml->packagename ?? '');
if ($packageName !== '') {
$element = $packageName;
}
}
// Fallback element detection
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); }
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); }
if ($element === '') {
$element = strtolower(basename($file, '.xml'));
if (in_array($element, ['templatedetails', 'manifest'], true)) {
$element = strtolower(basename(dirname($file)));
}
}
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
if ($name === '') { $name = $element; }
return compact('name', 'type', 'element', 'group');
}
function typePrefix(array $meta): string
{
return match ($meta['type']) {
'plugin' => "plg_{$meta['group']}_",
'module' => 'mod_',
'component' => 'com_',
'template' => 'tpl_',
'package' => 'pkg_',
'library' => 'lib_',
default => '',
};
}
function resolveLanguageKey(string $srcDir, string $key): ?string
{
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iter as $item) {
if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) {
foreach (file($item->getPathname()) as $line) {
if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) {
return $m[1];
}
}
}
}
return null;
}
function isExcluded(string $name): bool
{
if ($name === '.ftpignore') return true;
if (str_starts_with($name, 'sftp-config')) return true;
if (str_starts_with($name, '.env')) return true;
if (str_starts_with($name, '.build-trigger')) return true;
$ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
}
function buildZip(string $srcDir, string $outPath): void
{
$zip = new ZipArchive();
if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "::error::Cannot create ZIP: {$outPath}\n");
exit(1);
}
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $file) {
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
if (isExcluded(basename($local))) continue;
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
}
$zip->close();
}
function buildPackageZip(string $srcDir, string $outPath): void
{
fwrite(STDERR, "Building Joomla package (multi-extension)...\n");
$staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid();
mkdir($staging, 0755, true);
// 1. Zip each sub-extension in packages/
$packagesDir = "{$srcDir}/packages";
if (is_dir($packagesDir)) {
foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) {
$subManifest = findManifest($extDir);
if ($subManifest) {
$sub = parseManifest($subManifest);
$subPrefix = typePrefix($sub);
$subZipName = "{$subPrefix}{$sub['element']}.zip";
} else {
$subZipName = basename($extDir) . '.zip';
}
fwrite(STDERR, " Sub-extension: {$subZipName}\n");
buildZip($extDir, "{$staging}/{$subZipName}");
}
}
// 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) {
copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
}
}
// 3. Create outer zip
buildZip($staging, $outPath);
// Cleanup
rmTree($staging);
}
function copyTree(string $src, string $dst): void
{
if (!is_dir($dst)) mkdir($dst, 0755, true);
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $item) {
$target = "{$dst}/" . $iter->getSubPathname();
$item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target);
}
}
function rmTree(string $dir): void
{
if (!is_dir($dir)) return;
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
+128 -120
View File
@@ -1,136 +1,144 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_compat_check.php * PATH: /cli/joomla_compat_check.php
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version * BRIEF: Check if extension targetplatform regex matches the latest Joomla version
*
* Usage:
* php joomla_compat_check.php --path /repo
* php joomla_compat_check.php --path /repo --github-output
*
* Options:
* --path Repository root (default: .)
* --github-output Export results to $GITHUB_OUTPUT
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$ghOutput = false;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true; class JoomlaCompatCheckCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Check if extension targetplatform regex matches the latest Joomla version');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$ghOutput = $this->getArgument('--github-output');
$root = realpath($path) ?: $path;
// -- Find manifest and extract targetplatform --
$manifest = null;
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f);
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
$manifest = $f;
break 2;
}
}
}
if ($manifest === null) {
$this->log('ERROR', 'No manifest with targetplatform found');
return 1;
}
$xml = file_get_contents($manifest);
$relManifest = str_replace($root . '/', '', $manifest);
// Extract targetplatform version regex
$targetRegex = '';
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
$targetRegex = $m[1];
}
if (empty($targetRegex)) {
echo "No targetplatform version found in {$relManifest}\n";
return 1;
}
echo "Manifest: {$relManifest}\n";
echo "Target regex: {$targetRegex}\n";
// -- Fetch latest Joomla version --
$joomlaVersions = [];
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
$updateXml = @file_get_contents($updateUrl);
if ($updateXml === false) {
// Fallback: try the LTS feed
$updateUrl = 'https://update.joomla.org/core/list.xml';
$updateXml = @file_get_contents($updateUrl);
}
if ($updateXml !== false) {
// Parse all version entries
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
$joomlaVersions = $matches[1] ?? [];
}
if (empty($joomlaVersions)) {
echo "WARNING: Could not fetch Joomla versions from update server\n";
echo "Tested URL: {$updateUrl}\n";
return 0;
}
// Sort and get latest
usort($joomlaVersions, 'version_compare');
$latestJoomla = end($joomlaVersions);
echo "Latest Joomla: {$latestJoomla}\n";
// -- Test compatibility --
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
if ($compatible === false) {
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
$result = 'error';
} elseif ($compatible === 1) {
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
$result = 'pass';
} else {
// Check which major versions are supported
$supported = [];
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
if (@preg_match("/{$targetRegex}/", $v)) {
$supported[] = $v;
}
}
echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n";
echo "Supported versions: " . implode(', ', $supported) . "\n";
echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n";
$result = 'warn';
}
// -- Export --
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND);
file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND);
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
}
}
return $result === 'error' ? 1 : 0;
}
} }
$root = realpath($path) ?: $path; $app = new JoomlaCompatCheckCli();
exit($app->execute());
// ── Find manifest and extract targetplatform ────────────────────────────
$manifest = null;
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) continue;
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f);
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
$manifest = $f;
break 2;
}
}
}
if ($manifest === null) {
fwrite(STDERR, "No manifest with targetplatform found\n");
exit(1);
}
$xml = file_get_contents($manifest);
$relManifest = str_replace($root . '/', '', $manifest);
// Extract targetplatform version regex
$targetRegex = '';
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
$targetRegex = $m[1];
}
if (empty($targetRegex)) {
echo "No targetplatform version found in {$relManifest}\n";
exit(1);
}
echo "Manifest: {$relManifest}\n";
echo "Target regex: {$targetRegex}\n";
// ── Fetch latest Joomla version ─────────────────────────────────────────
$joomlaVersions = [];
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
$updateXml = @file_get_contents($updateUrl);
if ($updateXml === false) {
// Fallback: try the LTS feed
$updateUrl = 'https://update.joomla.org/core/list.xml';
$updateXml = @file_get_contents($updateUrl);
}
if ($updateXml !== false) {
// Parse all version entries
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
$joomlaVersions = $matches[1] ?? [];
}
if (empty($joomlaVersions)) {
echo "WARNING: Could not fetch Joomla versions from update server\n";
echo "Tested URL: {$updateUrl}\n";
exit(0);
}
// Sort and get latest
usort($joomlaVersions, 'version_compare');
$latestJoomla = end($joomlaVersions);
echo "Latest Joomla: {$latestJoomla}\n";
// ── Test compatibility ──────────────────────────────────────────────────
// The targetplatform regex uses Joomla's regex format
// Common patterns: "5\.[0-9]+" or "((5.[0-9])|(6.[0-9]))"
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
if ($compatible === false) {
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
$result = 'error';
} elseif ($compatible === 1) {
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
$result = 'pass';
} else {
// Check which major versions are supported
$supported = [];
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
if (@preg_match("/{$targetRegex}/", $v)) {
$supported[] = $v;
}
}
echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n";
echo "Supported versions: " . implode(', ', $supported) . "\n";
echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n";
$result = 'warn';
}
// ── Export ───────────────────────────────────────────────────────────────
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND);
file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND);
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
}
}
exit($result === 'error' ? 1 : 0);
+507
View File
@@ -0,0 +1,507 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_metadata_validate.php
* VERSION: 09.38.00
* BRIEF: Validate MokoGitea repo metadata against Joomla extension manifest XML
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class JoomlaMetadataValidateCli extends CliFramework
{
/** Joomla element prefix map — must match MokoGitea's cleanJoomlaElement() */
private const JOOMLA_PREFIX = [
'package' => 'pkg_',
'component' => 'com_',
'module' => 'mod_',
'template' => 'tpl_',
'library' => 'lib_',
'file' => 'file_',
];
protected function configure(): void
{
$this->setDescription('Validate MokoGitea repo metadata against Joomla extension manifest XML');
$this->addArgument('--path', 'Repo root path (default: current directory)', '.');
$this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
$this->addArgument('--org', 'Gitea org', 'MokoConsulting');
$this->addArgument('--repo', 'Repo name (auto-detected from git if empty)', '');
$this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
$this->addArgument('--ci', 'CI mode: exit 1 on any error', false);
$this->addArgument('--json', 'Output as JSON', false);
}
protected function run(): int
{
$path = realpath($this->getArgument('--path')) ?: $this->getArgument('--path');
$token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
$org = $this->getArgument('--org');
$repoName = $this->getArgument('--repo');
$apiBase = rtrim($this->getArgument('--api-base'), '/');
$ciMode = (bool) $this->getArgument('--ci');
$jsonMode = (bool) $this->getArgument('--json');
if (!is_dir($path)) {
$this->log('ERROR', "Path does not exist: {$path}");
return 1;
}
if ($repoName === '') {
$repoName = $this->detectRepoName($path);
}
// ── Step 1: Find the Joomla extension manifest XML ──────────
$joomlaXml = $this->findJoomlaManifest($path);
if ($joomlaXml === null) {
$this->log('ERROR', 'No Joomla extension manifest XML found');
return 1;
}
$this->log('INFO', "Joomla manifest: {$joomlaXml['path']}");
// ── Step 2: Load MokoGitea metadata ─────────────────────────
$metadata = $this->loadMetadata($path, $org, $repoName, $token, $apiBase);
if ($metadata === null) {
$this->log('ERROR', 'Could not load MokoGitea metadata');
return 1;
}
// ── Step 3: Compare ─────────────────────────────────────────
$results = $this->compare($metadata, $joomlaXml, $path);
// ── Step 4: Output ──────────────────────────────────────────
if ($jsonMode) {
echo json_encode([
'repo' => $repoName,
'results' => $results,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$this->printResults($repoName, $results);
}
$errors = count(array_filter($results, fn($r) => $r['status'] === 'error'));
return ($ciMode && $errors > 0) ? 1 : 0;
}
// =================================================================
// Find Joomla manifest XML
// =================================================================
private function findJoomlaManifest(string $root): ?array
{
// Search common locations for a Joomla extension manifest
$candidates = [];
// Package manifest: source/pkg_*.xml
foreach (glob("{$root}/source/pkg_*.xml") as $file) {
$candidates[] = $file;
}
// Component manifest: source/packages/com_*/[name].xml
foreach (glob("{$root}/source/packages/com_*/*.xml") as $file) {
$basename = basename($file);
// Skip access.xml, config.xml, etc.
if (in_array($basename, ['access.xml', 'config.xml'], true)) {
continue;
}
$candidates[] = $file;
}
// Direct source/*.xml
foreach (glob("{$root}/source/*.xml") as $file) {
if (basename($file) !== 'pkg_mokosuitebackup.xml') {
// Already caught above
}
$candidates[] = $file;
}
// src/ fallback
foreach (glob("{$root}/src/pkg_*.xml") as $file) {
$candidates[] = $file;
}
// Find the first one that has <extension type="...">
foreach (array_unique($candidates) as $file) {
$content = file_get_contents($file);
if ($content === false) {
continue;
}
if (preg_match('/<extension\s[^>]*type=["\']([^"\']+)["\']/', $content, $typeMatch)) {
$xml = @simplexml_load_string($content);
if ($xml === false) {
$relPath = str_replace($root . '/', '', $file);
$relPath = str_replace($root . '\\', '', $relPath);
$this->log('WARN', "Skipping {$relPath}: malformed XML");
continue;
}
$type = strtolower($typeMatch[1]);
$relPath = str_replace($root . '/', '', $file);
$relPath = str_replace($root . '\\', '', $relPath);
return [
'path' => $relPath,
'type' => $type,
'xml' => $xml,
];
}
}
return null;
}
// =================================================================
// Load metadata (from API)
// =================================================================
private function loadMetadata(string $root, string $org, string $repoName, string $token, string $apiBase): ?array
{
if ($token === '') {
$this->log('ERROR', 'No API token provided (use --token or set GITEA_TOKEN env var)');
return null;
}
$url = "{$apiBase}/repos/{$org}/{$repoName}/metadata";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 10,
'ignore_errors' => true,
],
]);
$body = file_get_contents($url, false, $ctx);
// Extract HTTP status from response headers
$httpCode = 0;
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) {
$httpCode = (int) $m[0];
}
if ($body === false) {
$this->log('ERROR', "Failed to connect to {$url} — check network or TLS configuration");
return null;
}
if ($httpCode === 404) {
$this->log('ERROR', "API endpoint not found: {$url}");
$this->log('ERROR', 'Server may need MokoGitea-Fork >= #650 (metadata endpoint rename)');
return null;
}
if ($httpCode === 401 || $httpCode === 403) {
$this->log('ERROR', "Authentication failed (HTTP {$httpCode}) — check your API token");
return null;
}
if ($httpCode >= 400) {
$this->log('ERROR', "API returned HTTP {$httpCode}: " . substr($body, 0, 200));
return null;
}
$data = json_decode($body, true);
if (!is_array($data)) {
$this->log('ERROR', "API returned invalid JSON from {$url}");
return null;
}
$data['source'] = 'api';
return $data;
}
// =================================================================
// Compare metadata against Joomla manifest
// =================================================================
private function compare(array $metadata, array $joomlaXml, string $root): array
{
$results = [];
$xml = $joomlaXml['xml'];
$type = $joomlaXml['type'];
// 1. Extension type
$metaType = $this->normalizeExtensionType(
$metadata['extension_type'] ?? $metadata['package_type'] ?? ''
);
$results[] = [
'field' => 'extension_type',
'metadata' => $metaType,
'joomla' => $type,
'status' => ($metaType === $type) ? 'ok' : 'error',
'message' => ($metaType === $type)
? "matches <extension type=\"{$type}\">"
: "metadata has \"{$metaType}\" but Joomla manifest has \"{$type}\"",
];
// 2. Element name
$metaName = strtolower($metadata['name'] ?? '');
$metaElement = $this->deriveElement($metaType, $metaName);
$joomlaElement = $this->extractJoomlaElement($xml, $type);
$elementMatch = ($metaElement === $joomlaElement);
$results[] = [
'field' => 'element',
'metadata' => $metaElement,
'joomla' => $joomlaElement,
'status' => $elementMatch ? 'ok' : 'error',
'message' => $elementMatch
? "derived correctly"
: "metadata derives \"{$metaElement}\" but Joomla uses \"{$joomlaElement}\"",
];
// 3. Version
$metaVersion = $metadata['version'] ?? '';
$joomlaVersion = (string) ($xml->version ?? '');
if ($metaVersion !== '' && $joomlaVersion !== '') {
// Strip dev/rc suffixes for comparison (CI bumps these)
$metaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $metaVersion);
$joomlaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $joomlaVersion);
$versionMatch = ($metaBase === $joomlaBase);
$results[] = [
'field' => 'version',
'metadata' => $metaVersion,
'joomla' => $joomlaVersion,
'status' => $versionMatch ? 'ok' : 'warn',
'message' => $versionMatch
? 'matches (base version)'
: "metadata has \"{$metaVersion}\" but Joomla has \"{$joomlaVersion}\"",
];
}
// 4. PHP minimum (from composer.json)
$composerPhp = $this->readComposerPhpRequirement($root);
$metaPhp = $metadata['php_minimum'] ?? '';
if ($composerPhp !== '' && $metaPhp !== '') {
$phpMatch = ($metaPhp === $composerPhp);
$results[] = [
'field' => 'php_minimum',
'metadata' => $metaPhp,
'joomla' => $composerPhp . ' (composer.json)',
'status' => $phpMatch ? 'ok' : 'warn',
'message' => $phpMatch
? 'matches composer.json'
: "metadata has \"{$metaPhp}\" but composer.json requires \"{$composerPhp}\"",
];
}
// 5. Description
$metaDesc = $metadata['description'] ?? '';
$joomlaDesc = (string) ($xml->description ?? '');
// Joomla descriptions are often language keys, skip those
if ($metaDesc !== '' && $joomlaDesc !== '' && !str_starts_with($joomlaDesc, 'COM_') && !str_starts_with($joomlaDesc, 'PKG_')) {
$descMatch = ($metaDesc === $joomlaDesc);
$results[] = [
'field' => 'description',
'metadata' => substr($metaDesc, 0, 60) . (strlen($metaDesc) > 60 ? '...' : ''),
'joomla' => substr($joomlaDesc, 0, 60) . (strlen($joomlaDesc) > 60 ? '...' : ''),
'status' => $descMatch ? 'ok' : 'info',
'message' => $descMatch ? 'matches' : 'descriptions differ (informational)',
];
}
return $results;
}
// =================================================================
// Helpers
// =================================================================
/**
* Normalize extension_type — map MokoGitea types to Joomla types.
*/
private function normalizeExtensionType(string $type): string
{
return match (strtolower($type)) {
'joomla-extension' => 'package', // legacy mapping
default => strtolower($type),
};
}
/**
* Derive the Joomla element name from type + name.
* Replicates MokoGitea's cleanJoomlaElement() + prefix logic.
*/
private function deriveElement(string $type, string $name): string
{
// Clean: lowercase, strip non-alphanumeric except . _ -
$clean = strtolower($name);
$clean = preg_replace('/[^a-z0-9._-]/', '', $clean);
$prefix = self::JOOMLA_PREFIX[$type] ?? '';
return $prefix . $clean;
}
/**
* Extract the element name from a Joomla manifest XML.
* Follows the same logic as Joomla's InstallerAdapter::getElement().
*/
private function extractJoomlaElement(\SimpleXMLElement $xml, string $type): string
{
switch ($type) {
case 'package':
$packagename = (string) ($xml->packagename ?? '');
if ($packagename !== '') {
return 'pkg_' . strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $packagename));
}
break;
case 'component':
$element = (string) ($xml->element ?? '');
if ($element !== '') {
$element = strtolower($element);
return str_starts_with($element, 'com_') ? $element : 'com_' . $element;
}
$name = (string) ($xml->name ?? '');
$name = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name));
return str_starts_with($name, 'com_') ? $name : 'com_' . $name;
case 'module':
$element = (string) ($xml->element ?? '');
if ($element !== '') {
return strtolower($element);
}
break;
case 'plugin':
// Plugins derive element from the file attribute
if (isset($xml->files)) {
foreach ($xml->files->children() as $file) {
$plugin = (string) ($file->attributes()->plugin ?? '');
if ($plugin !== '') {
return strtolower($plugin);
}
}
}
break;
case 'library':
$libname = (string) ($xml->libraryname ?? '');
if ($libname !== '') {
return strtolower($libname);
}
break;
}
// Fallback: use <name> tag
$name = (string) ($xml->name ?? '');
return strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name));
}
/**
* Read PHP version requirement from composer.json.
*/
private function readComposerPhpRequirement(string $root): string
{
$composerFile = "{$root}/composer.json";
if (!is_file($composerFile)) {
return '';
}
$data = json_decode(file_get_contents($composerFile), true);
if (!is_array($data)) {
return '';
}
$phpReq = $data['require']['php'] ?? '';
// Extract version number from constraint like ">=8.1"
if (preg_match('/(\d+\.\d+)/', $phpReq, $m)) {
return $m[1];
}
return '';
}
private function detectRepoName(string $root): string
{
$gitConfig = "{$root}/.git/config";
if (!file_exists($gitConfig)) {
return basename($root);
}
$content = file_get_contents($gitConfig);
if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
return $m[1];
}
return basename($root);
}
// =================================================================
// Output
// =================================================================
private function printResults(string $repoName, array $results): void
{
$errors = count(array_filter($results, fn($r) => $r['status'] === 'error'));
$warns = count(array_filter($results, fn($r) => $r['status'] === 'warn'));
$oks = count(array_filter($results, fn($r) => $r['status'] === 'ok'));
$this->log('INFO', "Validating {$repoName} Joomla metadata...\n");
foreach ($results as $r) {
$icon = match ($r['status']) {
'ok' => "\xE2\x9C\x93", // ✓
'error' => "\xE2\x9C\x97", // ✗
'warn' => "\xE2\x9A\xA0", // ⚠
default => "\xE2\x84\xB9", //
};
$line = sprintf(
" %s %-16s %s",
$icon,
$r['field'],
$r['message']
);
$this->log(
match ($r['status']) {
'error' => 'ERROR',
'warn' => 'WARN',
'ok' => 'OK',
default => 'INFO',
},
$line
);
}
echo "\n";
if ($errors > 0) {
$this->log('ERROR', "{$errors} error(s) — update delivery will fail");
} elseif ($warns > 0) {
$this->log('WARN', "All critical checks passed, {$warns} warning(s)");
} else {
$this->log('OK', "All {$oks} checks passed");
}
}
}
$app = new JoomlaMetadataValidateCli();
exit($app->execute());
+54 -26
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,9 +8,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_release.php * PATH: /cli/joomla_release.php
* BRIEF: Joomla release pipeline — build ZIP+tar.gz, upload to GitHub Release, update updates.xml * BRIEF: Joomla release pipeline — build ZIP+tar.gz, upload to GitHub Release, update updates.xml
* *
@@ -24,7 +25,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory}; use MokoCli\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory, SourceResolver};
/** /**
* Joomla Release Manager * Joomla Release Manager
@@ -36,7 +37,7 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapte
*/ */
class JoomlaRelease extends CliFramework class JoomlaRelease extends CliFramework
{ {
private const VERSION = '04.06.00'; private const VERSION = '09.23.00';
private const ORG = 'MokoConsulting'; private const ORG = 'MokoConsulting';
private const STABILITY_TAGS = [ private const STABILITY_TAGS = [
@@ -55,17 +56,17 @@ class JoomlaRelease extends CliFramework
'stable' => '', 'stable' => '',
]; ];
private ApiClient $api; private ApiClient $api;
private \MokoEnterprise\GitPlatformAdapter $adapter; private \MokoCli\GitPlatformAdapter $adapter;
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml'); $this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml');
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', ''); $this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.'); $this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
$this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable'); $this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable');
$this->addArgument('--dry-run', 'Preview without making changes', false); $this->addArgument('--dry-run', 'Preview without making changes', false);
$this->addArgument('--verbose', 'Show detailed output', false); $this->addArgument('--verbose', 'Show detailed output', false);
} }
protected function run(): int protected function run(): int
@@ -86,7 +87,9 @@ class JoomlaRelease extends CliFramework
if ($repo !== '') { if ($repo !== '') {
$path = $this->cloneRepo($repo); $path = $this->cloneRepo($repo);
if ($path === null) { return 1; } if ($path === null) {
return 1;
}
} }
$path = rtrim($path, '/\\'); $path = rtrim($path, '/\\');
@@ -118,11 +121,12 @@ class JoomlaRelease extends CliFramework
$this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}"); $this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}");
// ── Step 3: Build packages ──────────────────────────────────── // ── Step 3: Build packages ────────────────────────────────────
$srcDir = is_dir("{$path}/src") ? "{$path}/src" : (is_dir("{$path}/htdocs") ? "{$path}/htdocs" : null); $srcDir = SourceResolver::resolveAbsolute($path);
if ($srcDir === null) { if ($srcDir === null) {
$this->log('ERROR', 'No src/ or htdocs/ directory'); $this->log('ERROR', 'No source/ or src/ directory');
return 1; return 1;
} }
SourceResolver::warnIfLegacy($path);
$prefix = $this->typePrefix($meta); $prefix = $this->typePrefix($meta);
$zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip"; $zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip";
@@ -191,7 +195,9 @@ class JoomlaRelease extends CliFramework
private function findManifest(string $path): ?string private function findManifest(string $path): ?string
{ {
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) { foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
if (!is_dir($dir)) { continue; } if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") as $file) { foreach (glob("{$dir}/*.xml") as $file) {
if (str_contains((string) file_get_contents($file), '<extension')) { if (str_contains((string) file_get_contents($file), '<extension')) {
return $file; return $file;
@@ -235,7 +241,9 @@ class JoomlaRelease extends CliFramework
private function readVersion(string $path): ?string private function readVersion(string $path): ?string
{ {
$readme = "{$path}/README.md"; $readme = "{$path}/README.md";
if (!is_file($readme)) { return null; } if (!is_file($readme)) {
return null;
}
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) { if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) {
return $m[1]; return $m[1];
} }
@@ -301,8 +309,12 @@ class JoomlaRelease extends CliFramework
} }
// 2. Copy package-level files (manifest, script, language) // 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") as $f) { copy($f, "{$staging}/" . basename($f)); } foreach (glob("{$srcDir}/*.xml") as $f) {
foreach (glob("{$srcDir}/*.php") as $f) { copy($f, "{$staging}/" . basename($f)); } copy($f, "{$staging}/" . basename($f));
}
foreach (glob("{$srcDir}/*.php") as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (['language', 'administrator'] as $d) { foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) { if (is_dir("{$srcDir}/{$d}")) {
$this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}"); $this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
@@ -321,7 +333,9 @@ class JoomlaRelease extends CliFramework
*/ */
private function copyDir(string $src, string $dst): void private function copyDir(string $src, string $dst): void
{ {
if (!is_dir($dst)) { mkdir($dst, 0755, true); } if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
$iter = new \RecursiveIteratorIterator( $iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS), new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST \RecursiveIteratorIterator::SELF_FIRST
@@ -342,7 +356,9 @@ class JoomlaRelease extends CliFramework
); );
foreach ($iter as $file) { foreach ($iter as $file) {
$local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname())); $local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname()));
if ($this->isExcluded(basename($local))) { continue; } if ($this->isExcluded(basename($local))) {
continue;
}
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
} }
$zip->close(); $zip->close();
@@ -359,17 +375,29 @@ class JoomlaRelease extends CliFramework
private function isExcluded(string $name): bool private function isExcluded(string $name): bool
{ {
if ($name === '.ftpignore') { return true; } if ($name === '.ftpignore') {
if (str_starts_with($name, 'sftp-config')) { return true; } return true;
if (str_starts_with($name, '.env')) { return true; } }
if (str_starts_with($name, 'sftp-config')) {
return true;
}
if (str_starts_with($name, '.env')) {
return true;
}
$ext = pathinfo($name, PATHINFO_EXTENSION); $ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key'], true); return in_array($ext, ['ppk', 'pem', 'key'], true);
} }
// ── GitHub Release ─────────────────────────────────────────────── // ── GitHub Release ───────────────────────────────────────────────
private function ensureRelease(string $repo, string $tag, string $version, string $stability, string $extName = '', string $packageName = ''): void private function ensureRelease(
{ string $repo,
string $tag,
string $version,
string $stability,
string $extName = '',
string $packageName = ''
): void {
$releaseName = $extName !== '' $releaseName = $extName !== ''
? "{$extName} {$version} ({$packageName})" ? "{$extName} {$version} ({$packageName})"
: (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})"); : (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})");
@@ -379,7 +407,7 @@ class JoomlaRelease extends CliFramework
$this->api->post("/repos/{$repo}/releases", [ $this->api->post("/repos/{$repo}/releases", [
'tag_name' => $tag, 'tag_name' => $tag,
'name' => $releaseName, 'name' => $releaseName,
'body' => "## {$version}\n\nCreated by MokoStandards release pipeline.", 'body' => "## {$version}\n\nCreated by mokocli release pipeline.",
'prerelease' => ($stability !== 'stable'), 'prerelease' => ($stability !== 'stable'),
]); ]);
} }
+686
View File
@@ -0,0 +1,686 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/license_manage.php
* BRIEF: Manage license packages and keys via MokoGitea licensing API
*
* Usage:
* php bin/moko license:list --org MokoConsulting
* php bin/moko license:create-package --org MokoConsulting --name "Pro Annual" --duration 365 --max-sites 5
* php bin/moko license:issue --org MokoConsulting --package-id 1 --licensee "Client Inc" --email client@example.com
* php bin/moko license:revoke --org MokoConsulting --key-id 42
* php bin/moko license:renew --org MokoConsulting --key-id 42 --days 365
* php bin/moko license:validate --key MOKO-ABCD-1234-EF56-7890 --domain example.com
* php bin/moko license:usage --org MokoConsulting --key-id 42
* php bin/moko license:master-key --org MokoConsulting
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class LicenseManage extends CliFramework
{
private string $apiBase = '';
private string $token = '';
private string $subcommand = '';
protected function configure(): void
{
$this->setDescription('Manage license packages and keys via MokoGitea licensing API');
$this->addArgument('--org', 'Organization name', '');
$this->addArgument('--api-base', 'Gitea API base URL', '');
$this->addArgument('--token', 'API token (or set GH_TOKEN env)', '');
// Package args
$this->addArgument('--name', 'Package name (for create-package)', '');
$this->addArgument('--description', 'Package description', '');
$this->addArgument('--duration', 'Duration in days (0 = lifetime)', '0');
$this->addArgument('--max-sites', 'Max sites per key (0 = unlimited)', '0');
$this->addArgument('--repo-scope', 'Repo scope: all or comma-separated repo IDs', 'all');
$this->addArgument('--channels', 'Allowed channels: JSON array or comma-separated', '');
// Key args
$this->addArgument('--package-id', 'License package ID', '');
$this->addArgument('--key-id', 'License key ID', '');
$this->addArgument('--key', 'Raw license key string (for validate)', '');
$this->addArgument('--licensee', 'Licensee name', '');
$this->addArgument('--email', 'Licensee email', '');
$this->addArgument('--domain', 'Domain restriction or validation domain', '');
$this->addArgument('--domains', 'Comma-separated allowed domains', '');
$this->addArgument('--payment-ref', 'Payment reference (idempotency key)', '');
$this->addArgument('--days', 'Days to extend (for renew)', '365');
$this->addArgument('--custom-key', 'Use a custom key string instead of auto-generated', '');
// Output
$this->addArgument('--json', 'Output as JSON', false);
}
protected function initialize(): void
{
// Resolve API base
$this->apiBase = $this->getArgument('--api-base')
?: getenv('GITEA_URL')
?: 'https://git.mokoconsulting.tech';
$this->apiBase = rtrim($this->apiBase, '/');
// Resolve token
$this->token = $this->getArgument('--token')
?: getenv('GH_TOKEN')
?: getenv('GITHUB_TOKEN')
?: '';
if (empty($this->token)) {
$ghToken = trim((string) @shell_exec('gh auth token 2>/dev/null'));
if (!empty($ghToken)) {
$this->token = $ghToken;
}
}
// Determine subcommand from argv
global $argv;
foreach ($argv as $arg) {
if (
in_array($arg, [
'list', 'create-package', 'update-package', 'delete-package',
'issue', 'revoke', 'activate', 'renew', 'validate',
'usage', 'master-key', 'keys', 'packages',
], true)
) {
$this->subcommand = $arg;
break;
}
}
}
protected function run(): int
{
if (empty($this->token)) {
$this->log('No API token found. Set GH_TOKEN or pass --token.', 'ERROR');
return 1;
}
return match ($this->subcommand) {
'packages', 'list' => $this->listPackages(),
'create-package' => $this->createPackage(),
'update-package' => $this->updatePackage(),
'delete-package' => $this->deletePackage(),
'keys' => $this->listKeys(),
'issue' => $this->issueKey(),
'revoke' => $this->revokeKey(),
'activate' => $this->activateKey(),
'renew' => $this->renewKey(),
'validate' => $this->validateKey(),
'usage' => $this->viewUsage(),
'master-key' => $this->ensureMasterKey(),
default => $this->showSubcommandHelp(),
};
}
// ── Subcommand help ──────────────────────────────────────────────────
private function showSubcommandHelp(): int
{
$this->section('License Management — Subcommands');
echo <<<HELP
Package Management:
packages List all license packages for an org
create-package Create a new license package
update-package Update a license package (--package-id required)
delete-package Delete a license package (--package-id required)
Key Management:
keys List all license keys for an org
issue Issue a new license key (--package-id required)
revoke Deactivate a license key (--key-id required)
activate Re-activate a revoked key (--key-id required)
renew Extend key expiration (--key-id, --days required)
validate Validate a raw key string (--key required)
master-key Ensure master key exists for org
Analytics:
usage View usage logs for a key (--key-id required)
Examples:
php bin/moko license packages --org MokoConsulting
php bin/moko license create-package --org MokoConsulting --name "Pro Annual" --duration 365
php bin/moko license issue --org MokoConsulting --package-id 1 --licensee "Client"
php bin/moko license validate --key MOKO-ABCD-1234-EF56-7890 --domain example.com
php bin/moko license renew --org MokoConsulting --key-id 42 --days 365
HELP;
return 0;
}
// ── Package operations ───────────────────────────────────────────────
private function listPackages(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
$result = $this->apiGet("/orgs/{$org}/license-packages");
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$this->section("License Packages — {$org}");
if (empty($result)) {
$this->log('No packages found.', 'WARN');
return 0;
}
foreach ($result as $pkg) {
$duration = ($pkg['duration_days'] ?? 0) === 0 ? 'lifetime' : ($pkg['duration_days'] . ' days');
$sites = ($pkg['max_sites'] ?? 0) === 0 ? 'unlimited' : (string)$pkg['max_sites'];
$active = ($pkg['is_active'] ?? true) ? 'active' : 'inactive';
$this->status(
sprintf('#%d %s', $pkg['id'] ?? 0, $pkg['name'] ?? ''),
true,
sprintf('%s | %s sites | %s', $duration, $sites, $active)
);
}
return 0;
}
private function createPackage(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
$name = $this->getArgument('--name');
if (empty($name)) {
$this->log('--name is required for create-package', 'ERROR');
return 1;
}
$channels = $this->getArgument('--channels');
if (!empty($channels) && $channels[0] !== '[') {
$channels = json_encode(explode(',', $channels));
}
$data = [
'name' => $name,
'description' => $this->getArgument('--description') ?: '',
'duration_days' => (int) $this->getArgument('--duration'),
'max_sites' => (int) $this->getArgument('--max-sites'),
'repo_scope' => $this->getArgument('--repo-scope'),
'allowed_channels' => $channels ?: '',
];
if ($this->isDryRun()) {
$this->log('Would create package: ' . json_encode($data, JSON_PRETTY_PRINT), 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-packages", $data);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$this->log(sprintf('Created package #%d: %s', $result['id'] ?? 0, $name), 'OK');
}
return 0;
}
private function updatePackage(): int
{
$org = $this->requireOrg();
$pkgId = $this->getArgument('--package-id');
if ($org === null || empty($pkgId)) {
$this->log('--org and --package-id are required', 'ERROR');
return 1;
}
$data = array_filter([
'name' => $this->getArgument('--name') ?: null,
'description' => $this->getArgument('--description') ?: null,
'duration_days' => $this->getArgument('--duration') !== '0' ? (int)$this->getArgument('--duration') : null,
'max_sites' => $this->getArgument('--max-sites') !== '0' ? (int)$this->getArgument('--max-sites') : null,
], fn($v) => $v !== null);
if (empty($data)) {
$this->log('No fields to update. Pass --name, --description, --duration, or --max-sites.', 'WARN');
return 1;
}
if ($this->isDryRun()) {
$this->log("Would update package #{$pkgId}: " . json_encode($data), 'DRY-RUN');
return 0;
}
$result = $this->apiPatch("/orgs/{$org}/license-packages/{$pkgId}", $data);
if ($result === null) {
return 1;
}
$this->log("Updated package #{$pkgId}", 'OK');
return 0;
}
private function deletePackage(): int
{
$org = $this->requireOrg();
$pkgId = $this->getArgument('--package-id');
if ($org === null || empty($pkgId)) {
$this->log('--org and --package-id are required', 'ERROR');
return 1;
}
if ($this->isDryRun()) {
$this->log("Would delete package #{$pkgId}", 'DRY-RUN');
return 0;
}
$result = $this->apiDelete("/orgs/{$org}/license-packages/{$pkgId}");
if ($result === null) {
return 1;
}
$this->log("Deleted package #{$pkgId}", 'OK');
return 0;
}
// ── Key operations ───────────────────────────────────────────────────
private function listKeys(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
$pkgId = $this->getArgument('--package-id');
$endpoint = $pkgId
? "/orgs/{$org}/license-packages/{$pkgId}/keys"
: "/orgs/{$org}/license-keys";
$result = $this->apiGet($endpoint);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$this->section("License Keys — {$org}" . ($pkgId ? " (Package #{$pkgId})" : ''));
if (empty($result)) {
$this->log('No keys found.', 'WARN');
return 0;
}
foreach ($result as $key) {
$prefix = $key['key_prefix'] ?? '???';
$licensee = $key['licensee_name'] ?? 'N/A';
$active = ($key['is_active'] ?? true) ? 'active' : 'revoked';
$internal = ($key['is_internal'] ?? false) ? ' [MASTER]' : '';
$domains = $key['domain_restriction'] ?? '';
$expires = ($key['expires_unix'] ?? 0) > 0
? date('Y-m-d', (int) $key['expires_unix'])
: 'never';
$this->status(
sprintf('#%d %s', $key['id'] ?? 0, $prefix),
$key['is_active'] ?? true,
sprintf('%s | %s | expires: %s | domains: %s%s', $licensee, $active, $expires, $domains ?: 'any', $internal)
);
}
return 0;
}
private function issueKey(): int
{
$org = $this->requireOrg();
$pkgId = $this->getArgument('--package-id');
if ($org === null || empty($pkgId)) {
$this->log('--org and --package-id are required', 'ERROR');
return 1;
}
$data = [
'package_id' => (int) $pkgId,
'licensee_name' => $this->getArgument('--licensee') ?: '',
'licensee_email' => $this->getArgument('--email') ?: '',
'domain_restriction' => $this->getArgument('--domains') ?: '',
'max_sites' => (int) $this->getArgument('--max-sites'),
'payment_ref' => $this->getArgument('--payment-ref') ?: '',
];
$customKey = $this->getArgument('--custom-key');
if (!empty($customKey)) {
$data['custom_key'] = $customKey;
}
if ($this->isDryRun()) {
$this->log('Would issue key: ' . json_encode($data, JSON_PRETTY_PRINT), 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-keys", $data);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$rawKey = $result['raw_key'] ?? '';
$this->section('License Key Issued');
if (!empty($rawKey)) {
echo "\n";
$this->log("Raw Key: {$rawKey}", 'OK');
$this->log('This key will NOT be shown again. Save it now.', 'WARN');
echo "\n";
}
$this->log(sprintf('Key ID: #%d | Prefix: %s', $result['id'] ?? 0, $result['key_prefix'] ?? ''), 'INFO');
}
return 0;
}
private function revokeKey(): int
{
return $this->toggleKey(false);
}
private function activateKey(): int
{
return $this->toggleKey(true);
}
private function toggleKey(bool $activate): int
{
$org = $this->requireOrg();
$keyId = $this->getArgument('--key-id');
if ($org === null || empty($keyId)) {
$this->log('--org and --key-id are required', 'ERROR');
return 1;
}
$action = $activate ? 'activate' : 'revoke';
if ($this->isDryRun()) {
$this->log("Would {$action} key #{$keyId}", 'DRY-RUN');
return 0;
}
$result = $this->apiPatch("/orgs/{$org}/license-keys/{$keyId}", [
'is_active' => $activate,
]);
if ($result === null) {
return 1;
}
$label = $activate ? 'Activated' : 'Revoked';
$this->log("{$label} key #{$keyId}", 'OK');
return 0;
}
private function renewKey(): int
{
$org = $this->requireOrg();
$keyId = $this->getArgument('--key-id');
$days = (int) $this->getArgument('--days');
if ($org === null || empty($keyId)) {
$this->log('--org and --key-id are required', 'ERROR');
return 1;
}
if ($this->isDryRun()) {
$this->log("Would renew key #{$keyId} by {$days} days", 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-keys/{$keyId}/renew", [
'days' => $days,
]);
if ($result === null) {
return 1;
}
$newExpiry = isset($result['expires_unix']) && $result['expires_unix'] > 0
? date('Y-m-d', (int) $result['expires_unix'])
: 'never';
$this->log("Renewed key #{$keyId} — new expiry: {$newExpiry}", 'OK');
return 0;
}
private function validateKey(): int
{
$rawKey = $this->getArgument('--key');
if (empty($rawKey)) {
$this->log('--key is required for validate', 'ERROR');
return 1;
}
$data = ['key' => $rawKey];
$domain = $this->getArgument('--domain');
if (!empty($domain)) {
$data['domain'] = $domain;
}
$result = $this->apiPost('/license-keys/validate', $data);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$valid = $result['valid'] ?? false;
if ($valid) {
$this->status('License Valid', true, sprintf(
'Package: %s | Expires: %s | Sites: %s',
$result['package_name'] ?? 'N/A',
isset($result['expires_unix']) && $result['expires_unix'] > 0
? date('Y-m-d', (int) $result['expires_unix']) : 'never',
$result['max_sites'] ?? 'unlimited'
));
return 0;
} else {
$this->status('License Invalid', false, $result['error'] ?? 'Unknown reason');
return 1;
}
}
private function viewUsage(): int
{
$org = $this->requireOrg();
$keyId = $this->getArgument('--key-id');
if ($org === null || empty($keyId)) {
$this->log('--org and --key-id are required', 'ERROR');
return 1;
}
$result = $this->apiGet("/orgs/{$org}/license-keys/{$keyId}/usage");
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$this->section("Usage — Key #{$keyId}");
$entries = $result['entries'] ?? $result;
if (empty($entries)) {
$this->log('No usage recorded.', 'WARN');
return 0;
}
foreach ($entries as $u) {
$date = isset($u['created_unix']) ? date('Y-m-d H:i', (int) $u['created_unix']) : 'N/A';
$domain = $u['domain'] ?? '';
$ip = $u['ip_address'] ?? '';
$from = $u['version_from'] ?? '';
$this->log(sprintf('%s | %s | %s | from %s', $date, $domain ?: 'no domain', $ip, $from ?: 'unknown'), 'INFO');
}
return 0;
}
private function ensureMasterKey(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
if ($this->isDryRun()) {
$this->log("Would ensure master key for {$org}", 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-keys/master", []);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$rawKey = $result['raw_key'] ?? '';
if (!empty($rawKey)) {
$this->section('Master Key Created');
echo "\n";
$this->log("Raw Key: {$rawKey}", 'OK');
$this->log('This key will NOT be shown again. Save it now.', 'WARN');
echo "\n";
} else {
$this->log('Master key already exists.', 'INFO');
}
return 0;
}
// ── Helpers ──────────────────────────────────────────────────────────
private function requireOrg(): ?string
{
$org = $this->getArgument('--org');
if (empty($org)) {
// Try to detect from git remote
$remote = trim((string) @shell_exec('git remote get-url origin 2>/dev/null'));
if (preg_match('#[/:]([^/]+)/[^/]+?(?:\.git)?$#', $remote, $m)) {
$org = $m[1];
}
}
if (empty($org)) {
$this->log('--org is required (or must be detectable from git remote)', 'ERROR');
return null;
}
return $org;
}
private function apiGet(string $path): ?array
{
return $this->apiRequest('GET', $path);
}
private function apiPost(string $path, array $data): ?array
{
return $this->apiRequest('POST', $path, $data);
}
private function apiPatch(string $path, array $data): ?array
{
return $this->apiRequest('PATCH', $path, $data);
}
private function apiDelete(string $path): ?array
{
return $this->apiRequest('DELETE', $path);
}
private function apiRequest(string $method, string $path, ?array $data = null): ?array
{
$url = $this->apiBase . '/api/v1' . $path;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
'Authorization: token ' . $this->token,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_TIMEOUT => 30,
]);
if ($data !== null && in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
if ($this->getArgument('--verbose')) {
$this->log("{$method} {$url}", 'DEBUG');
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if (!empty($error)) {
$this->log("API error: {$error}", 'ERROR');
return null;
}
if ($httpCode === 404) {
$this->log("API endpoint not found: {$path}", 'ERROR');
$this->log('The licensing API may not be deployed yet. Check MokoGitea version.', 'WARN');
return null;
}
if ($httpCode === 204) {
return []; // success, no content
}
if ($httpCode >= 400) {
$body = json_decode((string) $response, true);
$msg = $body['message'] ?? $response;
$this->log("API error ({$httpCode}): {$msg}", 'ERROR');
return null;
}
$decoded = json_decode((string) $response, true);
if ($decoded === null && !empty($response)) {
$this->log('Failed to parse API response', 'ERROR');
return null;
}
return $decoded ?? [];
}
}
$app = new LicenseManage();
exit($app->execute());
+749
View File
@@ -0,0 +1,749 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_detect.php
* VERSION: 09.38.00
* BRIEF: Auto-detect manifest fields from source files and optionally push to API
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver};
class ManifestDetectCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Auto-detect manifest fields from source files');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--json', 'Output as JSON', false);
$this->addArgument('--diff', 'Show diff against current manifest API values', false);
$this->addArgument('--update', 'Push detected fields to manifest API', false);
$this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
$this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
$this->addArgument('--org', 'Gitea org', 'MokoConsulting');
$this->addArgument('--repo', 'Gitea repo name (auto-detected from remote if empty)', '');
$this->addArgument('--github-output', 'Append fields to $GITHUB_OUTPUT', false);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$jsonMode = (bool) $this->getArgument('--json');
$diffMode = (bool) $this->getArgument('--diff');
$updateMode = (bool) $this->getArgument('--update');
$ghOutput = (bool) $this->getArgument('--github-output');
$token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
$apiBase = rtrim($this->getArgument('--api-base'), '/');
$org = $this->getArgument('--org');
$repoName = $this->getArgument('--repo');
$root = realpath($path) ?: $path;
if (!is_dir($root)) {
$this->log('ERROR', "Path does not exist: {$path}");
return 1;
}
// Auto-detect repo name from git remote
if ($repoName === '') {
$repoName = $this->detectRepoName($root);
}
// ── Detect all fields ───────────────────────────────────────
$detected = $this->detectAll($root, $repoName);
// ── Warn about missing fields ────────────────────────────────
$expected = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point'];
foreach ($expected as $field) {
if (!isset($detected[$field]) || $detected[$field] === '') {
$this->log('WARN', "Could not detect: {$field}");
}
}
// ── Output ──────────────────────────────────────────────────
if ($diffMode || $updateMode) {
if ($token === '') {
$this->log('ERROR', 'API token required for --diff/--update (use --token or GITEA_TOKEN env)');
return 1;
}
if ($repoName === '') {
$this->log('ERROR', 'Could not determine repo name (use --repo)');
return 1;
}
$current = $this->fetchManifest($apiBase, $org, $repoName, $token);
if ($current === null) {
$this->log('ERROR', 'Failed to fetch current manifest from API');
return 1;
}
$changes = $this->computeDiff($current, $detected);
if ($diffMode) {
if (empty($changes)) {
$this->log('INFO', 'No differences — manifest matches source');
} else {
$this->sectionHeader('Manifest Drift');
foreach ($changes as $field => $info) {
$this->log('WARN', sprintf(
'%-20s API: %-30s Detected: %s',
$field,
$info['current'] === '' ? '(empty)' : $info['current'],
$info['detected']
));
}
}
}
if ($updateMode) {
if (empty($changes)) {
$this->log('INFO', 'Nothing to update');
} else {
$update = array_map(fn($i) => $i['detected'], $changes);
$ok = $this->pushManifest($apiBase, $org, $repoName, $token, $current, $update);
if ($ok) {
$this->log('OK', 'Updated ' . count($update) . ' field(s): ' . implode(', ', array_keys($update)));
} else {
$this->log('ERROR', 'Failed to push manifest update');
return 1;
}
}
}
return 0;
}
if ($ghOutput) {
$outputFile = getenv('GITHUB_OUTPUT');
$lines = [];
foreach ($detected as $k => $v) {
$envKey = str_replace('-', '_', $k);
$lines[] = "{$envKey}={$v}";
}
if ($outputFile !== false && $outputFile !== '') {
file_put_contents($outputFile, implode("\n", $lines) . "\n", FILE_APPEND);
$this->log('INFO', 'Wrote ' . count($detected) . ' fields to GITHUB_OUTPUT');
} else {
$this->log('WARN', 'GITHUB_OUTPUT not set — printing to stdout instead');
echo implode("\n", $lines) . "\n";
}
return 0;
}
if ($jsonMode) {
echo json_encode($detected, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
foreach ($detected as $k => $v) {
echo "{$k}={$v}\n";
}
}
return 0;
}
// =====================================================================
// Detection engine
// =====================================================================
private function detectAll(string $root, string $repoName): array
{
$platform = $this->detectPlatform($root);
$fields = [
'platform' => $platform,
'name' => '',
'description' => '',
'version' => '',
'element_name' => '',
'package_type' => '',
'language' => '',
'entry_point' => '',
'license_spdx' => '',
'display_name' => '',
'target_version' => '',
'php_minimum' => '',
];
switch ($platform) {
case 'joomla':
$this->detectJoomla($root, $repoName, $fields);
break;
case 'dolibarr':
$this->detectDolibarr($root, $repoName, $fields);
break;
case 'go':
$this->detectGo($root, $repoName, $fields);
break;
case 'mcp':
$this->detectNode($root, $repoName, $fields);
break;
case 'node':
$this->detectNode($root, $repoName, $fields);
$fields['platform'] = 'node';
break;
default:
$this->detectGeneric($root, $repoName, $fields);
break;
}
// Fallbacks
if ($fields['name'] === '') {
$fields['name'] = $repoName ?: basename($root);
}
if ($fields['entry_point'] === '') {
$fields['entry_point'] = $this->detectEntryPoint($root);
}
if ($fields['license_spdx'] === '') {
$fields['license_spdx'] = $this->detectLicense($root);
}
// description: only from platform-specific source, never guessed
// Strip empty values
return array_filter($fields, fn($v) => $v !== '');
}
// ── Platform detection ──────────────────────────────────────────
private function detectPlatform(string $root): string
{
// Joomla: look for pkg_*.xml or extension XML in source dirs
$joomlaXmls = array_merge(
SourceResolver::globSource($root, 'pkg_*.xml'),
glob("{$root}/pkg_*.xml") ?: []
);
if (!empty($joomlaXmls)) {
return 'joomla';
}
// Check source dirs for any Joomla extension XML
foreach (SourceResolver::globSource($root, '*.xml') as $xmlFile) {
$content = file_get_contents($xmlFile);
if (strpos($content, '<extension') !== false) {
return 'joomla';
}
}
// Dolibarr: mod*.class.php with DolibarrModules
$modFiles = array_merge(
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($modFiles as $file) {
if (strpos(file_get_contents($file), 'DolibarrModules') !== false) {
return 'dolibarr';
}
}
// Go
if (file_exists("{$root}/go.mod")) {
return 'go';
}
// MCP: package.json with mcp-related content
if (file_exists("{$root}/package.json")) {
$pkg = json_decode(file_get_contents("{$root}/package.json"), true) ?? [];
$deps = array_merge(
array_keys($pkg['dependencies'] ?? []),
array_keys($pkg['devDependencies'] ?? [])
);
foreach ($deps as $dep) {
if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') {
return 'mcp';
}
}
return 'node';
}
// Python
if (file_exists("{$root}/pyproject.toml") || file_exists("{$root}/setup.py")) {
return 'python';
}
return 'generic';
}
// ── Joomla ──────────────────────────────────────────────────────
private function detectJoomla(string $root, string $repoName, array &$fields): void
{
$fields['language'] = 'PHP';
// Find the primary extension manifest XML
$extManifest = $this->findJoomlaManifest($root);
if ($extManifest === null) {
return;
}
$xml = file_get_contents($extManifest);
// Type
$extType = '';
if (preg_match('/type="([^"]*)"/', $xml, $m)) {
$extType = $m[1];
}
$fields['package_type'] = $extType;
// Element name
$element = '';
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
$element = $m[1];
}
if ($element === '' && preg_match('/module="([^"]*)"/', $xml, $m)) {
$element = $m[1];
}
if ($element === '' && preg_match('/plugin="([^"]*)"/', $xml, $m)) {
$element = $m[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) {
$element = $m[1];
}
if ($element === '') {
$element = strtolower(basename($extManifest, '.xml'));
}
// Ensure element has type prefix (API stores full element_name like pkg_mokosuite)
$prefixMap = [
'package' => 'pkg_', 'component' => 'com_', 'module' => 'mod_',
'template' => 'tpl_', 'library' => 'lib_', 'file' => 'file_',
];
if (isset($prefixMap[$extType])) {
$prefix = $prefixMap[$extType];
// Only add prefix if not already present (check all known prefixes)
$hasPrefix = false;
foreach ($prefixMap as $p) {
if (strpos($element, $p) === 0) { $hasPrefix = true; break; }
}
if (strpos($element, 'plg_') === 0) { $hasPrefix = true; }
if (!$hasPrefix) {
$element = $prefix . $element;
}
} elseif ($extType === 'plugin') {
$folder = '';
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$folder = $gm[1];
}
if ($folder !== '' && strpos($element, 'plg_') !== 0) {
$element = "plg_{$folder}_" . $element;
}
}
$fields['element_name'] = $element;
// Name
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
$fields['name'] = trim($m[1]);
}
// Version
if (preg_match('/<version>([^<]+)<\/version>/', $xml, $m)) {
$fields['version'] = trim($m[1]);
}
// Description
if (preg_match('/<description>([^<]+)<\/description>/', $xml, $m)) {
$desc = trim($m[1]);
// Skip language string keys like COM_MOKOSUITE_DESCRIPTION
if (strpos($desc, '_') === false || strlen($desc) > 60) {
$fields['description'] = $desc;
}
}
// Display name for update feeds
if (!empty($fields['name'])) {
$name = $fields['name'];
// If name already has "Type - " prefix, use as-is
if (preg_match('/^(Package|Component|Module|Plugin|Template|Library)\s*-\s*/i', $name)) {
$fields['display_name'] = $name;
} elseif (!empty($extType)) {
$fields['display_name'] = ucfirst($extType) . ' - ' . $name;
}
}
// Target Joomla version
if (preg_match('/<targetplatform\s[^>]*version="([^"]+)"/', $xml, $m)) {
$fields['target_version'] = trim($m[1]);
} else {
// Default for Joomla 5/6
$fields['target_version'] = '(5|6)\..*';
}
// PHP minimum
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) {
$fields['php_minimum'] = trim($m[1]);
}
// License
if (preg_match('/<license>([^<]+)<\/license>/', $xml, $m)) {
$fields['license_spdx'] = $this->normalizeLicense(trim($m[1]));
}
}
private function findJoomlaManifest(string $root): ?string
{
// Priority: pkg_*.xml (package manifest)
$pkgXmls = array_merge(
SourceResolver::globSource($root, 'pkg_*.xml'),
glob("{$root}/pkg_*.xml") ?: []
);
if (!empty($pkgXmls)) {
return $pkgXmls[0];
}
// Any extension XML in source dir
foreach (SourceResolver::globSource($root, '*.xml') as $file) {
$content = file_get_contents($file);
if (strpos($content, '<extension') !== false) {
return $file;
}
}
// Root level
foreach (glob("{$root}/*.xml") ?: [] as $file) {
$content = file_get_contents($file);
if (strpos($content, '<extension') !== false) {
return $file;
}
}
return null;
}
// ── Dolibarr ────────────────────────────────────────────────────
private function detectDolibarr(string $root, string $repoName, array &$fields): void
{
$fields['language'] = 'PHP';
$fields['package_type'] = 'dolibarr-module';
$modFile = $this->findDolibarrModule($root);
if ($modFile === null) {
return;
}
$content = file_get_contents($modFile);
// Element name from class file
$modBasename = basename($modFile, '.class.php');
$fields['element_name'] = strtolower(preg_replace('/^mod/', '', $modBasename));
// Name
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
$fields['name'] = $m[1];
}
// Version
if (preg_match('/\$this->version\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
$fields['version'] = $m[1];
}
// Description
if (preg_match('/\$this->description\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
$desc = $m[1];
if (strpos($desc, '$') === false) {
$fields['description'] = $desc;
}
}
// License
if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) {
$fields['license_spdx'] = $m[1];
}
}
private function findDolibarrModule(string $root): ?string
{
$candidates = array_merge(
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($candidates as $file) {
if (strpos(file_get_contents($file), 'DolibarrModules') !== false) {
return $file;
}
}
return null;
}
// ── Go ──────────────────────────────────────────────────────────
private function detectGo(string $root, string $repoName, array &$fields): void
{
$fields['language'] = 'Go';
$fields['package_type'] = 'application';
$fields['entry_point'] = './';
$goMod = "{$root}/go.mod";
if (!file_exists($goMod)) {
return;
}
$content = file_get_contents($goMod);
// Module path → name
if (preg_match('/^module\s+(\S+)/m', $content, $m)) {
$modulePath = $m[1];
$parts = explode('/', $modulePath);
$fields['name'] = end($parts);
}
// Go version
if (preg_match('/^go\s+(\S+)/m', $content, $m)) {
// This is Go language version, not the project version
// Project version comes from git tags or source files
}
// License
$fields['license_spdx'] = $this->detectLicense($root);
}
// ── Node / MCP ──────────────────────────────────────────────────
private function detectNode(string $root, string $repoName, array &$fields): void
{
$pkgFile = "{$root}/package.json";
if (!file_exists($pkgFile)) {
return;
}
$pkg = json_decode(file_get_contents($pkgFile), true) ?? [];
$fields['name'] = $pkg['name'] ?? '';
// Strip npm scope
if (strpos($fields['name'], '/') !== false) {
$fields['name'] = explode('/', $fields['name'])[1];
}
$fields['version'] = $pkg['version'] ?? '';
$fields['description'] = $pkg['description'] ?? '';
$fields['license_spdx'] = $pkg['license'] ?? '';
// Language detection
if (file_exists("{$root}/tsconfig.json")) {
$fields['language'] = 'TypeScript';
} else {
$fields['language'] = 'JavaScript';
}
// Package type
$deps = array_merge(
array_keys($pkg['dependencies'] ?? []),
array_keys($pkg['devDependencies'] ?? [])
);
$isMcp = false;
foreach ($deps as $dep) {
if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') {
$isMcp = true;
break;
}
}
$fields['package_type'] = $isMcp ? 'mcp-server' : 'application';
// Entry point
if (file_exists("{$root}/dist")) {
$fields['entry_point'] = 'dist/';
} elseif (file_exists("{$root}/src")) {
$fields['entry_point'] = 'src/';
} else {
$fields['entry_point'] = './';
}
}
// ── Generic ─────────────────────────────────────────────────────
private function detectGeneric(string $root, string $repoName, array &$fields): void
{
$fields['package_type'] = 'generic';
// Try to detect language from file extensions
$fields['language'] = $this->detectLanguageFromFiles($root);
$fields['license_spdx'] = $this->detectLicense($root);
}
// =====================================================================
// Shared detection helpers
// =====================================================================
private function detectEntryPoint(string $root): string
{
$abs = SourceResolver::resolveAbsolute($root);
if ($abs !== null) {
return basename($abs) . '/';
}
if (is_dir("{$root}/dist")) return 'dist/';
if (is_dir("{$root}/src")) return 'src/';
return './';
}
private function detectLicense(string $root): string
{
// Check LICENSE file
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $name) {
$file = "{$root}/{$name}";
if (!file_exists($file)) continue;
$content = file_get_contents($file);
// SPDX header
if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) {
return $m[1];
}
// Common license patterns
if (strpos($content, 'GNU GENERAL PUBLIC LICENSE') !== false) {
if (strpos($content, 'Version 3') !== false) return 'GPL-3.0-or-later';
if (strpos($content, 'Version 2') !== false) return 'GPL-2.0-or-later';
}
if (strpos($content, 'MIT License') !== false) return 'MIT';
if (strpos($content, 'Apache License') !== false && strpos($content, 'Version 2.0') !== false) return 'Apache-2.0';
}
return '';
}
private function detectLanguageFromFiles(string $root): string
{
$counts = ['PHP' => 0, 'Go' => 0, 'TypeScript' => 0, 'JavaScript' => 0, 'Python' => 0, 'Shell' => 0];
$extensions = [
'php' => 'PHP', 'go' => 'Go', 'ts' => 'TypeScript',
'js' => 'JavaScript', 'py' => 'Python', 'sh' => 'Shell',
];
// Quick scan: only check top two levels
foreach (glob("{$root}/*") ?: [] as $item) {
$ext = pathinfo($item, PATHINFO_EXTENSION);
if (isset($extensions[$ext])) {
$counts[$extensions[$ext]]++;
}
if (is_dir($item) && basename($item)[0] !== '.') {
foreach (glob("{$item}/*") ?: [] as $subItem) {
$ext = pathinfo($subItem, PATHINFO_EXTENSION);
if (isset($extensions[$ext])) {
$counts[$extensions[$ext]]++;
}
}
}
}
arsort($counts);
$top = key($counts);
return $counts[$top] > 0 ? $top : '';
}
private function normalizeLicense(string $license): string
{
$lower = strtolower($license);
$isGpl = strpos($lower, 'gpl') !== false || strpos($lower, 'general public license') !== false;
if ($isGpl && strpos($lower, '3') !== false) return 'GPL-3.0-or-later';
if ($isGpl && strpos($lower, '2') !== false) return 'GPL-2.0-or-later';
if ($lower === 'mit' || strpos($lower, 'mit license') !== false) return 'MIT';
if (strpos($lower, 'apache') !== false) return 'Apache-2.0';
return $license;
}
private function detectRepoName(string $root): string
{
$gitConfig = "{$root}/.git/config";
if (!file_exists($gitConfig)) {
return basename($root);
}
$content = file_get_contents($gitConfig);
if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
return $m[1];
}
return basename($root);
}
// =====================================================================
// API interaction
// =====================================================================
private function fetchManifest(string $apiBase, string $org, string $repo, string $token): ?array
{
$url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 10,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) return null;
return json_decode($body, true);
}
private function computeDiff(array $current, array $detected): array
{
// Map detected keys to API keys (underscores match)
$changes = [];
foreach ($detected as $key => $value) {
$apiKey = $key;
$currentVal = $current[$apiKey] ?? '';
// Only flag as changed if detected value is non-empty and differs
if ($value !== '' && $value !== $currentVal) {
// Don't overwrite a non-empty API value with a detected value
// unless the API value is actually empty
if ($currentVal === '' || $this->shouldOverride($key, $currentVal, $value)) {
$changes[$key] = [
'current' => $currentVal,
'detected' => $value,
];
}
}
}
return $changes;
}
private function shouldOverride(string $field, string $current, string $detected): bool
{
// Version: detected from source is authoritative
if ($field === 'version') return true;
// These fields: source files are authoritative
if (in_array($field, ['element_name', 'package_type', 'language', 'entry_point'], true)) {
return true;
}
// For other fields, only fill empty — don't overwrite manual edits
return false;
}
private function pushManifest(string $apiBase, string $org, string $repo, string $token, array $current, array $update): bool
{
$merged = array_merge($current, $update);
$url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
$payload = json_encode($merged);
$ctx = stream_context_create([
'http' => [
'method' => 'PUT',
'header' => "Authorization: token {$token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$body = @file_get_contents($url, false, $ctx);
return $body !== false;
}
}
$app = new ManifestDetectCli();
exit($app->execute());
+166 -213
View File
@@ -6,233 +6,186 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_element.php * PATH: /cli/manifest_element.php
* BRIEF: Extract element name, type, type prefix, and ZIP name from manifest * BRIEF: Extract element name, type, type prefix, and ZIP name from manifest
*
* Usage:
* php manifest_element.php --path .
* php manifest_element.php --path . --version 09.01.00 --stability dev --github-output
*
* Detects platform (joomla, dolibarr, generic) and resolves:
* ext_element — canonical element name (e.g. mokojgdpc)
* ext_type — extension type (plugin, module, component, package, etc.)
* ext_folder — group/folder for plugins (e.g. system)
* ext_name — human-readable name (e.g. "Moko JGDPC")
* type_prefix — Joomla type prefix (plg_system_, com_, mod_, etc.)
* zip_name — computed ZIP filename
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$stability = 'stable';
$githubOutput = false;
$repoName = '';
foreach ($argv as $i => $arg) { use MokoCli\{CliFramework, SourceResolver};
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1]; class ManifestElementCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Version string', null);
$this->addArgument('--stability', 'Stability level', 'stable');
$this->addArgument('--repo', 'Repository name', '');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
} }
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--stability' && isset($argv[$i + 1])) {
$stability = $argv[$i + 1];
}
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoName = $argv[$i + 1];
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
$root = realpath($path) ?: $path; protected function run(): int
{
// ── Detect platform from manifest.xml ──────────────────────────────────────── $path = $this->getArgument('--path');
$platform = 'generic'; $version = $this->getArgument('--version');
$manifestXml = "{$root}/.mokogitea/manifest.xml"; $stability = $this->getArgument('--stability');
if (file_exists($manifestXml)) { $repoName = $this->getArgument('--repo');
$content = file_get_contents($manifestXml); $githubOutput = (bool) $this->getArgument('--github-output');
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) { $root = realpath($path) ?: $path;
$platform = trim($pm[1]); $platform = 'generic';
} $manifestXml = "{$root}/.mokogitea/manifest.xml";
} if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml);
// ── Find extension manifest (Joomla XML) ───────────────────────────────────── if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$extManifest = null; $platform = trim($pm[1]);
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $file) {
$c = file_get_contents($file);
if (strpos($c, '<extension') !== false) {
$extManifest = $file;
break;
}
}
// ── Find Dolibarr module file ────────────────────────────────────────────────
$modFile = null;
$modFiles = array_merge(
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($modFiles as $file) {
$c = file_get_contents($file);
if (strpos($c, 'extends DolibarrModules') !== false) {
$modFile = $file;
break;
}
}
// ── Extract metadata ─────────────────────────────────────────────────────────
$extElement = '';
$extType = '';
$extFolder = '';
$extName = '';
switch (true) {
// Joomla platforms
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest);
// Extension type and folder
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
// Element name: <element>, module= attribute, plugin= attribute, <packagename>, or filename
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
$extElement = $mm[1];
}
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
$extElement = $pm[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1];
}
if (empty($extElement)) {
$extElement = strtolower(basename($extManifest, '.xml'));
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
} }
} }
$extManifest = null;
// Human-readable name $manifestFiles = array_merge(SourceResolver::globSource($root, 'pkg_*.xml'), SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: []);
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) { foreach ($manifestFiles as $file) {
$extName = trim($nm[1]); $c = file_get_contents($file);
if (strpos($c, '<extension') !== false) {
$extManifest = $file;
break;
}
} }
break; $modFile = null;
$modFiles = array_merge(
// Dolibarr platforms SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: glob("{$root}/core/modules/mod*.class.php") ?: []
$extType = 'dolibarr-module'; );
$modBasename = basename($modFile, '.class.php'); foreach ($modFiles as $file) {
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename)); $c = file_get_contents($file);
if (strpos($c, 'extends DolibarrModules') !== false) {
$modContent = file_get_contents($modFile); $modFile = $file;
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) { break;
$extName = $nm[1]; }
} }
break; $extElement = '';
$extType = '';
// Generic / fallback $extFolder = '';
default: $extName = '';
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); switch (true) {
$extType = 'generic'; case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
break; $xml = file_get_contents($extManifest);
} if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
// ── Strip existing type prefix from element to prevent duplication ──────────── }
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
// ── Compute type prefix ────────────────────────────────────────────────────── }
$typePrefix = ''; if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
switch ($extType) { $extElement = $em[1];
case 'plugin': }
$typePrefix = "plg_{$extFolder}_"; if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
break; $extElement = $mm[1];
case 'module': }
$typePrefix = 'mod_'; if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
break; $extElement = $pm2[1];
case 'component': }
$typePrefix = 'com_'; if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
break; $extElement = $pn[1];
case 'template': }
$typePrefix = 'tpl_'; if (empty($extElement)) {
break; $extElement = strtolower(basename($extManifest, '.xml'));
case 'library': if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$typePrefix = 'lib_'; $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
break; }
case 'package': }
$typePrefix = 'pkg_'; if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
break; $extName = trim($nm[1]);
} }
break;
// ── Compute ZIP name ───────────────────────────────────────────────────────── case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
$suffixMap = [ $extType = 'dolibarr-module';
'development' => '-dev', $modBasename = basename($modFile, '.class.php');
'dev' => '-dev', $extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
'alpha' => '-alpha', $modContent = file_get_contents($modFile);
'beta' => '-beta', if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
'rc' => '-rc', $extName = $nm[1];
'release-candidate' => '-rc', }
'stable' => '', break;
]; default:
$suffix = $suffixMap[$stability] ?? ''; $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
$zipName = ''; $extType = 'generic';
if ($version !== null) { break;
$zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
}
// Fallback name
if (empty($extName)) {
$extName = $repoName ?: basename($root);
}
// ── Output ───────────────────────────────────────────────────────────────────
$outputs = [
'platform' => $platform,
'ext_element' => $extElement,
'ext_type' => $extType,
'ext_folder' => $extFolder,
'ext_name' => $extName,
'type_prefix' => $typePrefix,
'zip_name' => $zipName,
];
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [];
foreach ($outputs as $key => $value) {
$lines[] = "{$key}={$value}";
}
if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
} else {
// Fallback: echo ::set-output (legacy)
foreach ($outputs as $key => $value) {
echo "::set-output name={$key}::{$value}\n";
} }
} $extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
} else { $typePrefix = '';
foreach ($outputs as $key => $value) { switch ($extType) {
echo "{$key}={$value}\n"; case 'plugin':
$typePrefix = "plg_{$extFolder}_";
break;
case 'module':
$typePrefix = 'mod_';
break;
case 'component':
$typePrefix = 'com_';
break;
case 'template':
$typePrefix = 'tpl_';
break;
case 'library':
$typePrefix = 'lib_';
break;
case 'package':
$typePrefix = 'pkg_';
break;
}
$suffixMap = [
'development' => '-dev',
'dev' => '-dev',
'alpha' => '-alpha',
'beta' => '-beta',
'rc' => '-rc',
'release-candidate' => '-rc',
'stable' => '',
];
$suffix = $suffixMap[$stability] ?? '';
$zipName = '';
if ($version !== null) {
$zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
}
if (empty($extName)) {
$extName = $repoName ?: basename($root);
}
$outputs = [
'platform' => $platform,
'ext_element' => $extElement,
'ext_type' => $extType,
'ext_folder' => $extFolder,
'ext_name' => $extName,
'type_prefix' => $typePrefix,
'zip_name' => $zipName,
];
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [];
foreach ($outputs as $key => $value) {
$lines[] = "{$key}={$value}";
}
if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
} else {
foreach ($outputs as $key => $value) {
echo "::set-output name={$key}::{$value}\n";
}
}
} else {
foreach ($outputs as $key => $value) {
echo "{$key}={$value}\n";
}
}
return 0;
} }
} }
exit(0); $app = new ManifestElementCli();
exit($app->execute());
+564
View File
@@ -0,0 +1,564 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_integrity.php
* VERSION: 09.38.00
* BRIEF: Cross-check manifest API fields against repo contents across the org
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class ManifestIntegrityCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Cross-check manifest fields against repo contents across the org');
$this->addArgument('--path', 'Single repo path (local mode)', '');
$this->addArgument('--org', 'Gitea org (bulk mode)', 'MokoConsulting');
$this->addArgument('--repo', 'Single repo name (remote mode)', '');
$this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
$this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
$this->addArgument('--fix', 'Push fixes for detected drift', false);
$this->addArgument('--json', 'Output as JSON', false);
$this->addArgument('--quiet', 'Only show repos with issues', false);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$org = $this->getArgument('--org');
$repoName = $this->getArgument('--repo');
$token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
$apiBase = rtrim($this->getArgument('--api-base'), '/');
$fixMode = (bool) $this->getArgument('--fix');
$jsonMode = (bool) $this->getArgument('--json');
$quiet = (bool) $this->getArgument('--quiet');
if ($token === '') {
$this->log('ERROR', 'API token required (use --token or GITEA_TOKEN env)');
return 1;
}
// ── Mode selection ──────────────────────────────────────────
if ($path !== '') {
// Local mode: detect from source + compare to API
return $this->checkLocal($path, $org, $repoName, $token, $apiBase, $fixMode, $jsonMode);
}
if ($repoName !== '') {
// Single remote repo
return $this->checkRemoteRepo($org, $repoName, $token, $apiBase, $fixMode, $jsonMode);
}
// Bulk mode: all repos in org
return $this->checkOrg($org, $token, $apiBase, $fixMode, $jsonMode, $quiet);
}
// =====================================================================
// Local mode — detect from source, compare to API
// =====================================================================
private function checkLocal(string $path, string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int
{
$root = realpath($path) ?: $path;
if (!is_dir($root)) {
$this->log('ERROR', "Path does not exist: {$path}");
return 1;
}
if ($repoName === '') {
$repoName = $this->detectRepoName($root);
}
// Run manifest_detect logic
$detected = $this->runDetect($root, $repoName);
$current = $this->fetchManifest($apiBase, $org, $repoName, $token);
if ($current === null) {
$this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}");
return 1;
}
$issues = $this->validate($current, $detected, $repoName);
if ($json) {
echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$this->printIssues($repoName, $issues);
}
if ($fix && !empty($issues)) {
return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues);
}
return empty($issues) ? 0 : 1;
}
// =====================================================================
// Remote single repo mode — fetch source files via API
// =====================================================================
private function checkRemoteRepo(string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int
{
$current = $this->fetchManifest($apiBase, $org, $repoName, $token);
if ($current === null) {
$this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}");
return 1;
}
$issues = $this->validateManifestOnly($current, $repoName);
if ($json) {
echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$this->printIssues($repoName, $issues);
}
if ($fix && !empty($issues)) {
return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues);
}
return empty($issues) ? 0 : 1;
}
// =====================================================================
// Bulk org mode — check all repos
// =====================================================================
private function checkOrg(string $org, string $token, string $apiBase, bool $fix, bool $json, bool $quiet): int
{
$repos = $this->fetchOrgRepos($apiBase, $org, $token);
if ($repos === null) {
$this->log('ERROR', "Failed to fetch repos for org {$org}");
return 1;
}
$this->log('INFO', "Manifest Integrity Check — {$org} (" . count($repos) . " repos)");
$allResults = [];
$totalIssues = 0;
$reposWithIssues = 0;
foreach ($repos as $repo) {
$name = $repo['name'];
$manifest = $this->fetchManifest($apiBase, $org, $name, $token);
if ($manifest === null) {
if (!$quiet) {
$this->log('WARN', "{$name}: no manifest");
}
continue;
}
$issues = $this->validateManifestOnly($manifest, $name);
if (!empty($issues)) {
$reposWithIssues++;
$totalIssues += count($issues);
if ($json) {
$allResults[] = ['repo' => $name, 'issues' => $issues];
} else {
$this->printIssues($name, $issues);
}
if ($fix) {
$this->applyFixes($apiBase, $org, $name, $token, $manifest, $issues);
}
} elseif (!$quiet && !$json) {
$this->log('OK', "{$name}: clean");
}
}
if ($json) {
echo json_encode($allResults, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
echo "\n";
$level = $reposWithIssues > 0 ? 'WARN' : 'OK';
$this->log($level, sprintf(
'Summary: %d repos checked, %d with issues (%d total issues)',
count($repos),
$reposWithIssues,
$totalIssues
));
}
return $reposWithIssues > 0 ? 1 : 0;
}
// =====================================================================
// Validation rules
// =====================================================================
/**
* Full validation: compare API manifest against locally-detected fields.
*/
private function validate(array $current, array $detected, string $repoName): array
{
$issues = [];
// Required fields that should never be empty
$required = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point'];
foreach ($required as $field) {
if (empty($current[$field])) {
$fix = $detected[$field] ?? null;
$issues[] = [
'field' => $field,
'severity' => 'error',
'message' => 'Missing required field',
'current' => '',
'fix' => $fix,
];
}
}
// Drift detection: detected value differs from API
foreach ($detected as $field => $detectedValue) {
$currentValue = $current[$field] ?? '';
if ($detectedValue !== '' && $currentValue !== '' && $detectedValue !== $currentValue) {
// Version drift is expected on dev branches (suffix)
if ($field === 'version' && strpos($detectedValue, $currentValue) === 0) {
continue; // e.g., detected "02.34.50-dev" vs API "02.34.50"
}
if ($field === 'version' && strpos($currentValue, $detectedValue) === 0) {
continue;
}
$issues[] = [
'field' => $field,
'severity' => 'warn',
'message' => 'Drift: source differs from manifest',
'current' => $currentValue,
'fix' => $detectedValue,
];
}
}
// Platform-specific structure validation
$platform = $current['platform'] ?? '';
$issues = array_merge($issues, $this->validatePlatformStructure($platform, $current, $repoName));
return $issues;
}
/**
* API-only validation: check manifest fields for completeness and consistency
* without access to source files.
*/
private function validateManifestOnly(array $manifest, string $repoName): array
{
$issues = [];
// Required fields
$required = ['platform', 'name', 'version', 'language'];
foreach ($required as $field) {
if (empty($manifest[$field])) {
$issues[] = [
'field' => $field,
'severity' => 'error',
'message' => 'Missing required field',
'current' => '',
'fix' => null,
];
}
}
// Recommended fields
$recommended = ['package_type', 'entry_point', 'license_spdx', 'description'];
foreach ($recommended as $field) {
if (empty($manifest[$field])) {
$issues[] = [
'field' => $field,
'severity' => 'info',
'message' => 'Recommended field is empty',
'current' => '',
'fix' => null,
];
}
}
// Platform-specific checks
$platform = $manifest['platform'] ?? '';
$issues = array_merge($issues, $this->validatePlatformStructure($platform, $manifest, $repoName));
return $issues;
}
/**
* Platform-specific validation rules.
*/
private function validatePlatformStructure(string $platform, array $manifest, string $repoName): array
{
$issues = [];
switch ($platform) {
case 'joomla':
case 'waas-component':
// Joomla repos must have element_name
if (empty($manifest['element_name'])) {
$issues[] = [
'field' => 'element_name',
'severity' => 'error',
'message' => 'Joomla repos require element_name',
'current' => '',
'fix' => null,
];
}
// Language should be PHP
if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') {
$issues[] = [
'field' => 'language',
'severity' => 'warn',
'message' => 'Joomla repos should have language=PHP',
'current' => $manifest['language'],
'fix' => 'PHP',
];
}
break;
case 'dolibarr':
case 'crm-module':
if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') {
$issues[] = [
'field' => 'language',
'severity' => 'warn',
'message' => 'Dolibarr repos should have language=PHP',
'current' => $manifest['language'],
'fix' => 'PHP',
];
}
break;
case 'go':
if (!empty($manifest['language']) && $manifest['language'] !== 'Go') {
$issues[] = [
'field' => 'language',
'severity' => 'warn',
'message' => 'Go repos should have language=Go',
'current' => $manifest['language'],
'fix' => 'Go',
];
}
break;
case 'mcp':
if (!empty($manifest['language']) && !in_array($manifest['language'], ['TypeScript', 'JavaScript'], true)) {
$issues[] = [
'field' => 'language',
'severity' => 'warn',
'message' => 'MCP repos should have language=TypeScript or JavaScript',
'current' => $manifest['language'],
'fix' => null,
];
}
break;
}
// Version format check: should be XX.YY.ZZ
$version = $manifest['version'] ?? '';
if ($version !== '' && !preg_match('/^\d{2}\.\d{2}\.\d{2}/', $version)) {
// Allow semver for node/go repos
if (!in_array($platform, ['mcp', 'node', 'go'], true)) {
$issues[] = [
'field' => 'version',
'severity' => 'info',
'message' => 'Version does not match XX.YY.ZZ format',
'current' => $version,
'fix' => null,
];
}
}
return $issues;
}
// =====================================================================
// Output
// =====================================================================
private function printIssues(string $repoName, array $issues): void
{
if (empty($issues)) {
return;
}
$errors = count(array_filter($issues, fn($i) => $i['severity'] === 'error'));
$warns = count(array_filter($issues, fn($i) => $i['severity'] === 'warn'));
$infos = count($issues) - $errors - $warns;
echo "\n";
$summary = [];
if ($errors > 0) $summary[] = "{$errors} error(s)";
if ($warns > 0) $summary[] = "{$warns} warning(s)";
if ($infos > 0) $summary[] = "{$infos} info";
$this->log($errors > 0 ? 'ERROR' : 'WARN', "{$repoName}" . implode(', ', $summary));
foreach ($issues as $issue) {
$icon = match ($issue['severity']) {
'error' => 'ERROR',
'warn' => 'WARN',
default => 'INFO',
};
$msg = sprintf(' %-18s %s', $issue['field'], $issue['message']);
if ($issue['current'] !== '') {
$msg .= " (current: {$issue['current']})";
}
if ($issue['fix'] !== null) {
$msg .= " → fix: {$issue['fix']}";
}
$this->log($icon, $msg);
}
}
// =====================================================================
// Fix application
// =====================================================================
private function applyFixes(string $apiBase, string $org, string $repo, string $token, array $current, array $issues): int
{
$fixes = [];
foreach ($issues as $issue) {
if ($issue['fix'] !== null && $issue['fix'] !== '') {
$fixes[$issue['field']] = $issue['fix'];
}
}
if (empty($fixes)) {
$this->log('INFO', "{$repo}: no auto-fixable issues");
return 0;
}
$merged = array_merge($current, $fixes);
$url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
$payload = json_encode($merged);
$ctx = stream_context_create([
'http' => [
'method' => 'PUT',
'header' => "Authorization: token {$token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) {
$this->log('ERROR', "{$repo}: failed to push fixes");
return 1;
}
$this->log('OK', "{$repo}: fixed " . implode(', ', array_keys($fixes)));
return 0;
}
// =====================================================================
// API helpers
// =====================================================================
private function fetchManifest(string $apiBase, string $org, string $repo, string $token): ?array
{
$url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 10,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) return null;
$data = json_decode($body, true);
return is_array($data) ? $data : null;
}
private function fetchOrgRepos(string $apiBase, string $org, string $token): ?array
{
$allRepos = [];
$page = 1;
$limit = 50;
while (true) {
$url = "{$apiBase}/orgs/{$org}/repos?page={$page}&limit={$limit}";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 15,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) return null;
$repos = json_decode($body, true);
if (!is_array($repos) || empty($repos)) break;
$allRepos = array_merge($allRepos, $repos);
if (count($repos) < $limit) break;
$page++;
}
// Filter out archived and empty repos
return array_filter($allRepos, fn($r) => !($r['archived'] ?? false) && !($r['empty'] ?? false));
}
// =====================================================================
// Detection (delegates to manifest_detect logic)
// =====================================================================
private function runDetect(string $root, string $repoName): array
{
$script = __DIR__ . '/manifest_detect.php';
$redirect = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
$cmd = sprintf(
'php %s --path %s --repo %s --json --quiet %s',
escapeshellarg($script),
escapeshellarg($root),
escapeshellarg($repoName),
$redirect
);
$output = shell_exec($cmd) ?? '';
// Extract JSON object from output (skip banner/log lines)
if (preg_match('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $output, $m)) {
$data = json_decode($m[0], true);
if (is_array($data)) {
return $data;
}
}
return [];
}
private function detectRepoName(string $root): string
{
$gitConfig = "{$root}/.git/config";
if (!file_exists($gitConfig)) {
return basename($root);
}
$content = file_get_contents($gitConfig);
if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
return $m[1];
}
return basename($root);
}
}
$app = new ManifestIntegrityCli();
exit($app->execute());
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_licensing.php
* VERSION: 09.38.00
* BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests
*/
declare(strict_types=1);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, SourceResolver};
/**
* Reads the <licensing> block from .mokogitea/manifest.xml and ensures that the
* Joomla extension manifest contains the correct <updateservers> and <dlid> tags.
*
* manifest.xml licensing block example:
*
* <licensing>
* <enabled>true</enabled>
* <dlid>true</dlid>
* <update-server>https://git.mokoconsulting.tech/{org}/{repo}/updates.xml</update-server>
* <update-server-name>MyExtension Updates</update-server-name>
* </licensing>
*
* Supports {org} and {repo} placeholders in update-server URL, resolved from
* the manifest's <identity> block or git remote.
*/
class ManifestLicensingCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Ensure licensing tags (updateservers, dlid) in Joomla extension manifests');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--fix', 'Apply fixes (default: dry-run check only)', false);
$this->addArgument('--github-output', 'Write results to $GITHUB_OUTPUT', false);
}
protected function run(): int
{
$root = realpath($this->getArgument('--path')) ?: $this->getArgument('--path');
$fix = (bool) $this->getArgument('--fix');
$ghOutput = (bool) $this->getArgument('--github-output');
// ── 1. Read manifest.xml ──────────────────────────────────────────
$manifestFile = "{$root}/.mokogitea/manifest.xml";
if (!file_exists($manifestFile)) {
$this->log('WARN', "No manifest.xml found at {$manifestFile}");
$this->outputResult($ghOutput, 'skipped', 'No manifest.xml');
return 0;
}
$xml = @simplexml_load_file($manifestFile);
if ($xml === false) {
$this->log('ERROR', "Failed to parse {$manifestFile}");
return 1;
}
// ── 2. Check if licensing is enabled ──────────────────────────────
if (!isset($xml->licensing) || (string) ($xml->licensing->enabled ?? '') !== 'true') {
$this->log('INFO', 'Licensing not enabled in manifest.xml — skipping');
$this->outputResult($ghOutput, 'skipped', 'Licensing not enabled');
return 0;
}
$licensingNode = $xml->licensing;
$dlidEnabled = ((string) ($licensingNode->dlid ?? 'true')) === 'true';
$updateServerUrl = (string) ($licensingNode->{'update-server'} ?? '');
$updateServerName = (string) ($licensingNode->{'update-server-name'} ?? '');
// ── 3. Resolve placeholders ───────────────────────────────────────
$org = (string) ($xml->identity->org ?? '');
$repo = (string) ($xml->identity->name ?? '');
// Fallback to git remote if manifest doesn't have org/name
if (empty($org) || empty($repo)) {
$remote = trim((string) @shell_exec("cd " . escapeshellarg($root) . " && git remote get-url origin 2>/dev/null"));
if (preg_match('#[/:]([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
if (empty($org)) {
$org = $m[1];
}
if (empty($repo)) {
$repo = $m[2];
}
}
}
// Default update server URL if not specified
if (empty($updateServerUrl) && !empty($org) && !empty($repo)) {
$updateServerUrl = "https://git.mokoconsulting.tech/{$org}/{$repo}/updates.xml";
}
// Resolve {org} and {repo} placeholders
$updateServerUrl = str_replace(['{org}', '{repo}'], [$org, $repo], $updateServerUrl);
// Default server name from display-name or repo name
if (empty($updateServerName)) {
$displayName = (string) ($xml->identity->{'display-name'} ?? $repo);
$updateServerName = $displayName . ' Updates';
}
if (empty($updateServerUrl)) {
$this->log('ERROR', 'Cannot determine update server URL — set <update-server> in manifest.xml or ensure org/repo are available');
return 1;
}
$this->log('INFO', "Licensing enabled — org={$org}, repo={$repo}");
$this->log('INFO', "Update server: {$updateServerUrl}");
$this->log('INFO', "DLID required: " . ($dlidEnabled ? 'yes' : 'no'));
// ── 4. Find Joomla extension manifests ────────────────────────────
$xmlFiles = array_merge(
SourceResolver::globSource($root, '*.xml'),
SourceResolver::globSource($root, 'packages/*/*.xml'),
glob("{$root}/*.xml") ?: []
);
$packageManifest = null;
foreach ($xmlFiles as $file) {
$content = file_get_contents($file);
if (!str_contains($content, '<extension')) {
continue;
}
// Find the package manifest (type="package") or the main extension manifest
if (str_contains($content, 'type="package"')) {
$packageManifest = $file;
break;
}
// Fallback: first extension manifest found
if ($packageManifest === null) {
$packageManifest = $file;
}
}
if ($packageManifest === null) {
$this->log('WARN', 'No Joomla extension manifest found');
$this->outputResult($ghOutput, 'skipped', 'No extension manifest');
return 0;
}
$relPath = str_replace($root . '/', '', str_replace('\\', '/', $packageManifest));
$this->log('INFO', "Package manifest: {$relPath}");
// ── 5. Check and fix the manifest ─────────────────────────────────
$content = file_get_contents($packageManifest);
$original = $content;
$changes = [];
// --- 5a. Ensure <updateservers> block with correct URL ---
if (preg_match('#<updateservers>\s*</updateservers>#s', $content)) {
// Empty updateservers block — inject the server
$replacement = "<updateservers>\n"
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
. " </updateservers>";
$content = preg_replace('#<updateservers>\s*</updateservers>#s', $replacement, $content);
$changes[] = 'Added update server URL to empty <updateservers>';
} elseif (!str_contains($content, '<updateservers>')) {
// No updateservers at all — add before </extension>
$serverBlock = "\n <updateservers>\n"
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
. " </updateservers>\n";
$content = str_replace('</extension>', $serverBlock . '</extension>', $content);
$changes[] = 'Added <updateservers> block';
} else {
// updateservers exists — verify URL is correct
if (preg_match('#<server[^>]*>([^<]+)</server>#', $content, $m)) {
if ($m[1] !== $updateServerUrl) {
$content = preg_replace(
'#(<server[^>]*>)[^<]+(</server>)#',
"\${1}{$updateServerUrl}\${2}",
$content
);
$changes[] = "Updated server URL: {$m[1]}{$updateServerUrl}";
}
}
}
// --- 5b. Ensure <dlid> tag if required ---
if ($dlidEnabled) {
if (!str_contains($content, '<dlid')) {
// Add before <updateservers> if present, otherwise before </extension>
$dlidTag = ' <dlid prefix="dlid=" suffix=""/>' . "\n";
if (str_contains($content, '<updateservers>')) {
$content = str_replace('<updateservers>', $dlidTag . "\n <updateservers>", $content);
} else {
$content = str_replace('</extension>', $dlidTag . '</extension>', $content);
}
$changes[] = 'Added <dlid> tag';
}
}
// --- 5c. Ensure <blockChildUninstall> for packages ---
if (str_contains($content, 'type="package"') && !str_contains($content, '<blockChildUninstall>')) {
$blockTag = ' <blockChildUninstall>true</blockChildUninstall>' . "\n";
if (str_contains($content, '<dlid')) {
// Add after <dlid>
$content = preg_replace(
'#(<dlid[^/]*/>\s*\n)#',
"\${1}{$blockTag}",
$content
);
} elseif (str_contains($content, '<updateservers>')) {
$content = str_replace('<updateservers>', $blockTag . "\n <updateservers>", $content);
} else {
$content = str_replace('</extension>', $blockTag . '</extension>', $content);
}
$changes[] = 'Added <blockChildUninstall>true</blockChildUninstall>';
}
// ── 6. Report and apply ───────────────────────────────────────────
if (empty($changes)) {
$this->log('INFO', 'All licensing tags are correct — no changes needed');
$this->outputResult($ghOutput, 'ok', 'No changes needed');
return 0;
}
foreach ($changes as $change) {
$this->log($fix ? 'INFO' : 'WARN', ($fix ? 'Fixed: ' : 'Needs fix: ') . $change);
}
if ($fix) {
file_put_contents($packageManifest, $content);
$this->log('INFO', "Wrote {$relPath} with " . count($changes) . " change(s)");
$this->outputResult($ghOutput, 'fixed', implode('; ', $changes));
} else {
$this->log('WARN', 'Run with --fix to apply changes');
$this->outputResult($ghOutput, 'needs-fix', implode('; ', $changes));
return 1;
}
return 0;
}
/**
* Write result to $GITHUB_OUTPUT if requested.
*/
private function outputResult(bool $ghOutput, string $status, string $detail): void
{
if (!$ghOutput) {
return;
}
$outputFile = getenv('GITHUB_OUTPUT');
if ($outputFile === false || $outputFile === '') {
echo "licensing_status={$status}\n";
echo "licensing_detail={$detail}\n";
return;
}
$fh = fopen($outputFile, 'a');
fwrite($fh, "licensing_status={$status}\n");
fwrite($fh, "licensing_detail={$detail}\n");
fclose($fh);
}
}
$app = new ManifestLicensingCli();
exit($app->execute());
+444 -145
View File
@@ -1,175 +1,474 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_read.php * PATH: /cli/manifest_read.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption * BRIEF: Read repo metadata from Gitea manifest API, auto-detect the rest
*
* Usage:
* php manifest_read.php --path /repo --field platform
* php manifest_read.php --path /repo --field entry-point
* php manifest_read.php --path /repo --all
* php manifest_read.php --path /repo --github-output
*
* Fields: name, org, description, license, license-spdx, platform,
* standards-version, standards-source, language, package-type, entry-point,
* source-dir, remote-subdir, excludes, dev-host, demo-host
*
* --all Print all fields as KEY=VALUE lines
* --github-output Append all fields to $GITHUB_OUTPUT (for Gitea/GitHub Actions)
* --json Output all fields as JSON
* --field <name> Print a single field value (no key, just value)
*/ */
declare(strict_types=1); declare(strict_types=1);
// -- Argument parsing --------------------------------------------------------- require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.';
$field = null;
$mode = 'field'; // field | all | github-output | json
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--field' && isset($argv[$i + 1])) $field = $argv[$i + 1];
if ($arg === '--all') $mode = 'all';
if ($arg === '--github-output') $mode = 'github-output';
if ($arg === '--json') $mode = 'json';
}
// -- Locate manifest ---------------------------------------------------------- class ManifestReadCli extends CliFramework
$root = realpath($path) ?: $path; {
$manifestFile = null; /** Joomla extension XML element names searched in root and source/ dirs. */
private const JOOMLA_XML_ROOTS = ['extension', 'install'];
// Priority: manifest.xml (current standard) protected function configure(): void
$candidates = [ {
"{$root}/.mokogitea/manifest.xml", $this->setDescription('Read repo metadata from Gitea API with auto-detection fallback');
"{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed) $this->addArgument('--path', 'Repository root path', '.');
"{$root}/.mokogitea/.moko-platform", // legacy v4 $this->addArgument('--field', 'Single field name to output', '');
]; $this->addArgument('--all', 'Print all fields as KEY=VALUE lines', false);
$this->addArgument('--github-output', 'Append all fields to $GITHUB_OUTPUT', false);
foreach ($candidates as $candidate) { $this->addArgument('--json', 'Output all fields as JSON', false);
if (file_exists($candidate)) {
$manifestFile = $candidate;
break;
} }
}
if ($manifestFile === null) { protected function run(): int
fwrite(STDERR, "No manifest found in {$root} {
"); $path = $this->getArgument('--path');
exit(1); $field = $this->getArgument('--field');
} $showAll = $this->getArgument('--all');
$ghOut = $this->getArgument('--github-output');
$jsonMode = $this->getArgument('--json');
// -- Parse XML ---------------------------------------------------------------- $mode = match (true) {
$xml = @simplexml_load_file($manifestFile); (bool) $ghOut => 'github-output',
(bool) $showAll => 'all',
(bool) $jsonMode => 'json',
default => 'field',
};
if ($xml === false) { $root = realpath($path) ?: $path;
// Fallback: try YAML format (.mokostandards legacy)
$content = file_get_contents($manifestFile);
$fields = [];
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
$fields['platform'] = trim($m[1], "
\"'"); // ── 1. Resolve org/repo ──────────────────────────────────────────
} [$org, $repo] = $this->resolveOrgRepo($root);
if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) {
$fields['standards-version'] = trim($m[1], "
\"'"); // ── 2. Primary: Gitea manifest API ───────────────────────────────
} $fields = null;
if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) { if ($org !== '' && $repo !== '') {
$fields['name'] = trim($m[1], " $fields = $this->fetchFromApi($org, $repo);
\"'");
}
} else {
// Register namespace for XPath (optional, simple path works without)
$fields = [
'name' => (string)($xml->identity->name ?? ''),
'display-name' => (string)($xml->identity->{"display-name"} ?? ''),
'org' => (string)($xml->identity->org ?? ''),
'description' => (string)($xml->identity->description ?? ''),
'license' => (string)($xml->identity->license ?? ''),
'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''),
'platform' => (string)($xml->governance->platform ?? ''),
'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''),
'standards-source' => (string)($xml->governance->{"standards-source"} ?? ''),
'language' => (string)($xml->build->language ?? ''),
'package-type' => (string)($xml->build->{"package-type"} ?? ''),
'entry-point' => (string)($xml->build->{"entry-point"} ?? ''),
'version' => (string)($xml->identity->version ?? ''),
'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
'excludes' => (string)($xml->deploy->excludes ?? ''),
'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''),
'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
'manifest-file' => $manifestFile,
];
}
// Strip empty values for cleaner output
$fields = array_filter($fields, fn($v) => $v !== '');
// -- Output -------------------------------------------------------------------
switch ($mode) {
case 'field':
if ($field === null) {
fwrite(STDERR, "Usage: manifest_read.php --path <dir> --field <name>
");
fwrite(STDERR, " manifest_read.php --path <dir> --all
");
fwrite(STDERR, " manifest_read.php --path <dir> --json
");
fwrite(STDERR, " manifest_read.php --path <dir> --github-output
");
exit(2);
} }
echo ($fields[$field] ?? '') . "
";
break;
case 'all': // ── 3. Fallback: auto-detect from source tree ────────────────────
foreach ($fields as $k => $v) { if ($fields === null) {
echo "{$k}={$v} $this->log('INFO', 'API unavailable — falling back to source-tree detection');
"; $fields = $this->autoDetect($root, $repo);
} }
break;
case 'json': if (empty($fields)) {
echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . " $this->log('ERROR', "Could not resolve metadata for {$root}");
"; return 1;
break; }
case 'github-output': // Provide backward-compatible aliases (hyphenated → underscore)
$outputFile = getenv('GITHUB_OUTPUT'); $fields = $this->addAliases($fields);
if ($outputFile === false || $outputFile === '') {
fwrite(STDERR, "GITHUB_OUTPUT not set — printing to stdout instead // Strip empty values
"); $fields = array_filter($fields, fn($v) => $v !== '' && $v !== null);
foreach ($fields as $k => $v) {
// Convert field-name to FIELD_NAME for env var style // ── 4. Output ────────────────────────────────────────────────────
$envKey = str_replace('-', '_', $k); return $this->outputFields($fields, $mode, $field);
echo "{$envKey}={$v} }
";
// ── Gitea manifest API ───────────────────────────────────────────────
private function fetchFromApi(string $org, string $repo): ?array
{
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: '';
$baseUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
$baseUrl = rtrim($baseUrl, '/');
if ($token === '') {
return null;
}
$url = "{$baseUrl}/api/v1/repos/{$org}/{$repo}/manifest";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 10,
'ignore_errors' => true,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) {
return null;
}
// Check HTTP status from response headers
$status = 0;
if (isset($http_response_header[0])) {
preg_match('/\d{3}/', $http_response_header[0], $m);
$status = (int) ($m[0] ?? 0);
}
if ($status < 200 || $status >= 300) {
return null;
}
$data = json_decode($body, true);
if (!is_array($data) || empty($data)) {
return null;
}
$this->log('INFO', "Loaded metadata from Gitea manifest API ({$org}/{$repo})");
return $data;
}
// ── Auto-detection fallback ──────────────────────────────────────────
private function autoDetect(string $root, string $repoName): array
{
$fields = [
'name' => $repoName ?: basename($root),
'org' => 'MokoConsulting',
];
// Resolve source directory (source/ or src/)
$srcDir = null;
foreach (['source', 'src'] as $candidate) {
if (is_dir("{$root}/{$candidate}")) {
$srcDir = $candidate;
break;
} }
} else {
$fh = fopen($outputFile, 'a');
foreach ($fields as $k => $v) {
$envKey = str_replace('-', '_', $k);
fwrite($fh, "{$envKey}={$v}
");
}
fclose($fh);
fwrite(STDERR, "Wrote " . count($fields) . " fields to GITHUB_OUTPUT
");
} }
break;
// ── Try Joomla detection ─────────────────────────────────────
$joomlaResult = $this->detectJoomla($root, $srcDir);
if ($joomlaResult !== null) {
$fields = array_merge($fields, $joomlaResult);
$this->log('INFO', "Auto-detected platform: joomla ({$fields['extension_type']}{$fields['element_name']})");
return $fields;
}
// ── Try Dolibarr detection ───────────────────────────────────
$dolibarrResult = $this->detectDolibarr($root);
if ($dolibarrResult !== null) {
$fields = array_merge($fields, $dolibarrResult);
$this->log('INFO', "Auto-detected platform: dolibarr");
return $fields;
}
// ── Generic fallback ─────────────────────────────────────────
$fields['platform'] = $this->detectGenericPlatform($root);
$fields['element_name'] = strtolower($fields['name']);
$fields['extension_type'] = 'application';
$fields['language'] = $this->detectLanguage($root);
if ($srcDir !== null) {
$fields['entry_point'] = "{$srcDir}/";
}
$this->log('INFO', "Auto-detected platform: {$fields['platform']}");
return $fields;
}
/**
* Detect Joomla platform by scanning for extension XML manifests.
*
* Searches root and source/ dirs for XML files containing <extension type="...">.
* Extracts element name from the filename (pkg_*, com_*, mod_*, plg_*, tpl_*) or
* from the <element> tag inside the manifest.
*/
private function detectJoomla(string $root, ?string $srcDir): ?array
{
$searchDirs = [$root];
if ($srcDir !== null) {
$searchDirs[] = "{$root}/{$srcDir}";
}
foreach ($searchDirs as $dir) {
$xmlFiles = glob("{$dir}/*.xml") ?: [];
foreach ($xmlFiles as $xmlFile) {
$content = @file_get_contents($xmlFile);
if ($content === false) {
continue;
}
// Match <extension type="component|module|plugin|package|template|file|library">
if (!preg_match('/<extension\s+[^>]*type="([^"]+)"/', $content, $typeMatch)) {
// Also try legacy <install type="...">
if (!preg_match('/<install\s+[^>]*type="([^"]+)"/', $content, $typeMatch)) {
continue;
}
}
$extType = strtolower($typeMatch[1]);
$basename = pathinfo($xmlFile, PATHINFO_FILENAME);
// Try to extract element name from XML <element> tag
$xml = @simplexml_load_string($content);
$element = '';
if ($xml !== false) {
// Package manifests have <files><file ...>element</file></files>
// Component/module manifests have <element> or use filename
$element = (string) ($xml->element ?? '');
if ($element === '') {
$element = strtolower($basename);
}
} else {
$element = strtolower($basename);
}
// Derive display name
$displayName = (string) ($xml->name ?? ucfirst(str_replace('_', ' ', $basename)));
return [
'platform' => 'joomla',
'extension_type' => $extType,
'element_name' => $element,
'display_name' => $displayName,
'language' => 'PHP',
'entry_point' => ($srcDir ?? '.') . '/',
];
}
// Also check for pkg_*.xml pattern specifically
$pkgFiles = glob("{$dir}/pkg_*.xml") ?: [];
if (!empty($pkgFiles)) {
$basename = pathinfo($pkgFiles[0], PATHINFO_FILENAME);
return [
'platform' => 'joomla',
'extension_type' => 'package',
'element_name' => strtolower($basename),
'display_name' => ucfirst(str_replace('_', ' ', $basename)),
'language' => 'PHP',
'entry_point' => ($srcDir ?? '.') . '/',
];
}
}
// Check for com_*/manifest.xml pattern (component subdirectory)
$comDirs = glob("{$root}/com_*", GLOB_ONLYDIR) ?: [];
foreach ($comDirs as $comDir) {
$comManifest = glob("{$comDir}/*.xml") ?: [];
foreach ($comManifest as $xmlFile) {
$content = @file_get_contents($xmlFile);
if ($content && preg_match('/<extension\s+[^>]*type="component"/', $content)) {
return [
'platform' => 'joomla',
'extension_type' => 'component',
'element_name' => strtolower(basename($comDir)),
'display_name' => ucfirst(str_replace('com_', '', basename($comDir))),
'language' => 'PHP',
'entry_point' => ($srcDir ?? '.') . '/',
];
}
}
}
return null;
}
/**
* Detect Dolibarr platform by scanning for module descriptor files.
*/
private function detectDolibarr(string $root): ?array
{
// Look for mod*.class.php containing DolibarrModules
$searchPaths = [
"{$root}/core/modules/mod*.class.php",
"{$root}/*/core/modules/mod*.class.php",
];
foreach ($searchPaths as $pattern) {
$files = glob($pattern) ?: [];
foreach ($files as $file) {
$content = @file_get_contents($file);
if ($content && str_contains($content, 'DolibarrModules')) {
$modName = pathinfo($file, PATHINFO_FILENAME);
// modMyModule.class → mymodule
$element = strtolower(preg_replace('/^mod/', '', str_replace('.class', '', $modName)));
return [
'platform' => 'dolibarr',
'extension_type' => 'module',
'element_name' => $element,
'display_name' => ucfirst($element),
'language' => 'PHP',
'entry_point' => './',
];
}
}
}
// Secondary: check for update.txt (Dolibarr marker)
if (file_exists("{$root}/update.txt")) {
return [
'platform' => 'dolibarr',
'extension_type' => 'module',
'element_name' => strtolower(basename($root)),
'display_name' => basename($root),
'language' => 'PHP',
'entry_point' => './',
];
}
return null;
}
/**
* Detect generic platform type (php, nodejs, python, etc.) from project files.
*/
private function detectGenericPlatform(string $root): string
{
if (file_exists("{$root}/composer.json")) {
return 'php';
}
if (file_exists("{$root}/package.json")) {
return 'nodejs';
}
if (file_exists("{$root}/pyproject.toml") || file_exists("{$root}/setup.py")) {
return 'python';
}
if (file_exists("{$root}/go.mod")) {
return 'go';
}
if (file_exists("{$root}/Cargo.toml")) {
return 'rust';
}
return 'generic';
}
/**
* Detect primary language from project files.
*/
private function detectLanguage(string $root): string
{
if (file_exists("{$root}/composer.json")) {
return 'PHP';
}
if (file_exists("{$root}/tsconfig.json")) {
return 'TypeScript';
}
if (file_exists("{$root}/package.json")) {
return 'JavaScript';
}
if (file_exists("{$root}/pyproject.toml") || file_exists("{$root}/setup.py")) {
return 'Python';
}
return '';
}
// ── Org/repo resolution ──────────────────────────────────────────────
/**
* Resolve org and repo name from environment or git remote.
*
* @return array{0: string, 1: string} [org, repo]
*/
private function resolveOrgRepo(string $root): array
{
// 1. GITHUB_REPOSITORY env (set in Gitea Actions / GitHub Actions)
$envRepo = getenv('GITHUB_REPOSITORY') ?: '';
if ($envRepo !== '' && str_contains($envRepo, '/')) {
return explode('/', $envRepo, 2);
}
// 2. Parse git remote origin URL
$remoteUrl = trim((string) shell_exec(
'git -C ' . escapeshellarg($root) . ' remote get-url origin 2>/dev/null'
));
if ($remoteUrl !== '') {
// SSH: git@host:Org/Repo.git or HTTPS: https://host/Org/Repo.git
if (preg_match('#[/:]([^/]+)/([^/]+?)(?:\.git)?$#', $remoteUrl, $m)) {
return [$m[1], $m[2]];
}
}
return ['', basename($root)];
}
// ── Backward-compatible aliases ──────────────────────────────────────
/**
* Add hyphenated aliases for underscore fields (backward compat with old manifest.xml consumers).
* Also map old field names to new ones.
*/
private function addAliases(array $fields): array
{
// Map API field names → old manifest.xml hyphenated names
$aliases = [
'display_name' => 'display-name',
'license_spdx' => 'license-spdx',
'license_name' => 'license',
'standards_version' => 'standards-version',
'standards_source' => 'standards-source',
'extension_type' => 'package-type',
'entry_point' => 'entry-point',
'element_name' => 'name',
];
foreach ($aliases as $newKey => $oldKey) {
if (isset($fields[$newKey]) && !isset($fields[$oldKey])) {
$fields[$oldKey] = $fields[$newKey];
}
}
return $fields;
}
// ── Output ───────────────────────────────────────────────────────────
private function outputFields(array $fields, string $mode, string $field): int
{
switch ($mode) {
case 'field':
if ($field === '') {
$this->log('ERROR', "Usage: manifest:read --path <dir> --field <name>");
$this->log('ERROR', " manifest:read --path <dir> --all");
$this->log('ERROR', " manifest:read --path <dir> --json");
$this->log('ERROR', " manifest:read --path <dir> --github-output");
return 2;
}
echo ($fields[$field] ?? '') . "\n";
break;
case 'all':
foreach ($fields as $k => $v) {
echo "{$k}={$v}\n";
}
break;
case 'json':
echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
break;
case 'github-output':
$outputFile = getenv('GITHUB_OUTPUT') ?: getenv('GITEA_OUTPUT') ?: '';
$lines = [];
foreach ($fields as $k => $v) {
$envKey = str_replace('-', '_', $k);
$lines[$envKey] = "{$envKey}={$v}\n";
}
// Deduplicate (aliases may collide after underscore conversion)
$output = implode('', $lines);
if ($outputFile === '') {
$this->log('WARNING', 'GITHUB_OUTPUT not set — printing to stdout');
echo $output;
} else {
file_put_contents($outputFile, $output, FILE_APPEND);
$this->log('INFO', "Wrote " . count($lines) . " fields to GITHUB_OUTPUT");
}
break;
}
return 0;
}
} }
exit(0); $app = new ManifestReadCli();
exit($app->execute());
+302 -317
View File
@@ -6,350 +6,335 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/package_build.php * PATH: /cli/package_build.php
* BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects * BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects
* *
* Usage:
* php package_build.php --path /repo --version 04.01.00
* php package_build.php --path /repo --version 04.01.00 --output-dir /tmp
* php package_build.php --path /repo --version 04.01.00 --github-output
*
* Options:
* --path Repository root (default: .)
* --version Version string (required)
* --output-dir Directory for built packages (default: /tmp)
* --type-prefix Override type prefix (e.g. plg_system_)
* --element Override element name
* --github-output Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT
*
* NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped. * NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped.
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$outputDir = '/tmp';
$typePrefixOverride = null;
$elementOverride = null;
$githubOutput = false;
foreach ($argv as $i => $arg) { use MokoCli\{CliFramework, SourceResolver};
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1]; class PackageBuildCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects');
$this->addArgument('--path', 'Repository root (default: .)', '.');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--output-dir', 'Directory for built packages (default: /tmp)', '/tmp');
$this->addArgument('--type-prefix', 'Override type prefix (e.g. plg_system_)', '');
$this->addArgument('--element', 'Override element name', '');
$this->addArgument('--github-output', 'Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT', false);
} }
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--output-dir' && isset($argv[$i + 1])) {
$outputDir = $argv[$i + 1];
}
if ($arg === '--type-prefix' && isset($argv[$i + 1])) {
$typePrefixOverride = $argv[$i + 1];
}
if ($arg === '--element' && isset($argv[$i + 1])) {
$elementOverride = $argv[$i + 1];
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
if ($version === null) { protected function run(): int
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n"); {
exit(1); $path = $this->getArgument('--path');
} $version = $this->getArgument('--version');
$outputDir = $this->getArgument('--output-dir');
$typePrefixOverride = $this->getArgument('--type-prefix') ?: null;
$elementOverride = $this->getArgument('--element') ?: null;
$githubOutput = $this->getArgument('--github-output');
$root = realpath($path) ?: $path; if ($version === '') {
$this->log('ERROR', 'Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]');
// Ensure output directory exists return 1;
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
// -- Determine source directory -----------------------------------------------
$sourceDir = null;
foreach (['src', 'htdocs'] as $candidate) {
if (is_dir("{$root}/{$candidate}")) {
$sourceDir = "{$root}/{$candidate}";
break;
}
}
if ($sourceDir === null) {
fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n");
exit(1);
}
// -- Determine element and type prefix from manifest --------------------------
$extElement = $elementOverride;
$typePrefix = $typePrefixOverride ?? '';
$extType = '';
$isPackage = false;
if ($extElement === null || $typePrefixOverride === null) {
// Find manifest
$manifest = null;
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
} }
}
if ($manifest === null) { $root = realpath($path) ?: $path;
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) { // Ensure output directory exists
$manifest = $f; if (!is_dir($outputDir)) {
break; mkdir($outputDir, 0755, true);
}
// -- Determine source directory -----------------------------------------------
$sourceDir = SourceResolver::resolveAbsolute($root);
if ($sourceDir === null) {
$this->log('ERROR', "No source/ or src/ directory found in {$root}");
return 1;
}
SourceResolver::warnIfLegacy($root);
// -- Determine element and type prefix from manifest --------------------------
$extElement = $elementOverride;
$typePrefix = $typePrefixOverride ?? '';
$extType = '';
$isPackage = false;
if ($extElement === null || $typePrefixOverride === null) {
// Find manifest
$manifest = null;
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
if ($manifest === null) {
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
}
if ($manifest !== null) {
$xml = file_get_contents($manifest);
if ($extElement === null) {
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
$extElement = $m[1];
} elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) {
$extElement = $m[1];
} elseif (preg_match('/module="([^"]+)"/', $xml, $m)) {
$extElement = $m[1];
} else {
$extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
}
}
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
$extType = $m[1];
}
$extFolder = '';
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
$extFolder = $m[1];
}
if ($typePrefixOverride === null) {
switch ($extType) {
case 'plugin':
$typePrefix = "plg_{$extFolder}_";
break;
case 'module':
$typePrefix = 'mod_';
break;
case 'component':
$typePrefix = 'com_';
break;
case 'template':
$typePrefix = 'tpl_';
break;
case 'library':
$typePrefix = 'lib_';
break;
case 'package':
$typePrefix = 'pkg_';
break;
}
}
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
} }
} }
}
if ($manifest !== null) {
$xml = file_get_contents($manifest);
if ($extElement === null) { if ($extElement === null) {
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) { $extElement = strtolower(basename($root));
$extElement = $m[1]; }
} elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) {
$extElement = $m[1]; // Prevent double prefix (e.g. pkg_pkg_mokogallery)
} elseif (preg_match('/module="([^"]+)"/', $xml, $m)) { if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
$extElement = $m[1]; $extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
} else { }
$extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
$zipName = "{$typePrefix}{$extElement}-{$version}.zip";
$tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz";
$zipPath = "{$outputDir}/{$zipName}";
$tarPath = "{$outputDir}/{$tarName}";
// -- Exclude patterns ---------------------------------------------------------
$excludePatterns = [
'.ftpignore',
'sftp-config*',
'*.ppk',
'*.pem',
'*.key',
'.env*',
];
// -- Build packages -----------------------------------------------------------
if ($isPackage) {
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
$packagesDir = "{$stagingDir}/packages";
mkdir($packagesDir, 0755, true);
// ZIP each sub-extension into packages/
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
$subName = basename($extDir);
echo " Packaging sub-extension: {$subName}\n";
$subZip = new \ZipArchive();
$subZipPath = "{$packagesDir}/{$subName}.zip";
if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
$this->log('ERROR', "Failed to create ZIP for {$subName}");
continue;
}
$this->addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
$subZip->close();
echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n";
} }
}
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) { // Copy package-level files (manifest, script.php, etc.)
$extType = $m[1]; foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
} copy($f, "{$stagingDir}/" . basename($f));
$extFolder = '';
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
$extFolder = $m[1];
}
if ($typePrefixOverride === null) {
switch ($extType) {
case 'plugin':
$typePrefix = "plg_{$extFolder}_";
break;
case 'module':
$typePrefix = 'mod_';
break;
case 'component':
$typePrefix = 'com_';
break;
case 'template':
$typePrefix = 'tpl_';
break;
case 'library':
$typePrefix = 'lib_';
break;
case 'package':
$typePrefix = 'pkg_';
break;
} }
}
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); // Copy language directory if present
} if (is_dir("{$sourceDir}/language")) {
} $langDest = "{$stagingDir}/language";
mkdir($langDest, 0755, true);
if ($extElement === null) { $langIterator = new \RecursiveIteratorIterator(
$extElement = strtolower(basename($root)); new \RecursiveDirectoryIterator("{$sourceDir}/language", \RecursiveDirectoryIterator::SKIP_DOTS),
} \RecursiveIteratorIterator::SELF_FIRST
);
// Prevent double prefix (e.g. pkg_pkg_mokogallery) foreach ($langIterator as $item) {
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) { $target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1);
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1); if ($item->isDir()) {
} mkdir($target, 0755, true);
} else {
$zipName = "{$typePrefix}{$extElement}-{$version}.zip"; copy($item->getPathname(), $target);
$tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz"; }
$zipPath = "{$outputDir}/{$zipName}"; }
$tarPath = "{$outputDir}/{$tarName}";
// -- Exclude patterns ---------------------------------------------------------
$excludePatterns = [
'.ftpignore',
'sftp-config*',
'*.ppk',
'*.pem',
'*.key',
'.env*',
];
// -- Build packages -----------------------------------------------------------
if ($isPackage) {
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
$packagesDir = "{$stagingDir}/packages";
mkdir($packagesDir, 0755, true);
// ZIP each sub-extension into packages/
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
$subName = basename($extDir);
echo " Packaging sub-extension: {$subName}\n";
$subZip = new ZipArchive();
$subZipPath = "{$packagesDir}/{$subName}.zip";
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP for {$subName}\n");
continue;
}
addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
$subZip->close();
echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n";
}
// Copy package-level files (manifest, script.php, etc.)
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
copy($f, "{$stagingDir}/" . basename($f));
}
// Copy language directory if present
if (is_dir("{$sourceDir}/language")) {
$langDest = "{$stagingDir}/language";
mkdir($langDest, 0755, true);
$langIterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator("{$sourceDir}/language", RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($langIterator as $item) {
$target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1);
if ($item->isDir()) {
mkdir($target, 0755, true);
} else {
copy($item->getPathname(), $target);
} }
}
}
// Create ZIP from staging // Create ZIP from staging
$zip = new ZipArchive(); $zip = new \ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n"); $this->log('ERROR', "Failed to create ZIP: {$zipPath}");
exit(1); return 1;
}
addDirectoryToZip($zip, $stagingDir, '', []);
$zip->close();
// Create tar.gz — all arguments are escaped via escapeshellarg()
$tarCmd = sprintf(
'tar -czf %s -C %s .',
escapeshellarg($tarPath),
escapeshellarg($stagingDir)
);
passthru($tarCmd, $tarReturn);
// Cleanup staging
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
passthru($cleanCmd);
} else {
echo "=== Building standard extension package ===\n";
// ZIP
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
exit(1);
}
addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
$zip->close();
// tar.gz — all arguments are escaped via escapeshellarg()
$excludeArgs = '';
foreach ($excludePatterns as $pattern) {
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
}
$tarCmd = sprintf(
'tar -czf %s -C %s%s .',
escapeshellarg($tarPath),
escapeshellarg($sourceDir),
$excludeArgs
);
passthru($tarCmd, $tarReturn);
}
// -- Calculate SHA-256 --------------------------------------------------------
$sha256Zip = hash_file('sha256', $zipPath);
$sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : '';
$zipSize = filesize($zipPath);
$tarSize = file_exists($tarPath) ? filesize($tarPath) : 0;
echo "\n";
echo "ZIP: {$zipName} ({$zipSize} bytes)\n";
echo " SHA-256: {$sha256Zip}\n";
if ($tarSize > 0) {
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
echo " SHA-256: {$sha256Tar}\n";
}
// -- Export to GITHUB_OUTPUT --------------------------------------------------
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"zip_name={$zipName}",
"tar_name={$tarName}",
"zip_path={$zipPath}",
"tar_path={$tarPath}",
"sha256_zip={$sha256Zip}",
"sha256_tar={$sha256Tar}",
"type_prefix={$typePrefix}",
"ext_element={$extElement}",
];
if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
} else {
foreach ($lines as $line) {
echo "{$line}\n";
}
}
}
exit(0);
// =============================================================================
// Helper: recursively add directory contents to a ZipArchive
// =============================================================================
function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $excludes): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
$filePath = $file->getPathname();
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
// Check excludes
$basename = basename($filePath);
$skip = false;
foreach ($excludes as $pattern) {
if (fnmatch($pattern, $basename)) {
$skip = true;
break;
} }
} $this->addDirectoryToZip($zip, $stagingDir, '', []);
if ($skip) { $zip->close();
continue;
}
// Normalize path separators for ZIP // Create tar.gz — all arguments are escaped via escapeshellarg()
$relativePath = str_replace('\\', '/', $relativePath); $tarCmd = sprintf(
'tar -czf %s -C %s .',
escapeshellarg($tarPath),
escapeshellarg($stagingDir)
);
passthru($tarCmd, $tarReturn);
if ($file->isDir()) { // Cleanup staging
$zip->addEmptyDir($relativePath); $cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
passthru($cleanCmd);
} else { } else {
$zip->addFile($filePath, $relativePath); echo "=== Building standard extension package ===\n";
// ZIP
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
$this->log('ERROR', "Failed to create ZIP: {$zipPath}");
return 1;
}
$this->addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
$zip->close();
// tar.gz — all arguments are escaped via escapeshellarg()
$excludeArgs = '';
foreach ($excludePatterns as $pattern) {
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
}
$tarCmd = sprintf(
'tar -czf %s -C %s%s .',
escapeshellarg($tarPath),
escapeshellarg($sourceDir),
$excludeArgs
);
passthru($tarCmd, $tarReturn);
}
// -- Calculate SHA-256 --------------------------------------------------------
$sha256Zip = hash_file('sha256', $zipPath);
$sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : '';
$zipSize = filesize($zipPath);
$tarSize = file_exists($tarPath) ? filesize($tarPath) : 0;
echo "\n";
echo "ZIP: {$zipName} ({$zipSize} bytes)\n";
echo " SHA-256: {$sha256Zip}\n";
if ($tarSize > 0) {
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
echo " SHA-256: {$sha256Tar}\n";
}
// -- Export to GITHUB_OUTPUT --------------------------------------------------
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"zip_name={$zipName}",
"tar_name={$tarName}",
"zip_path={$zipPath}",
"tar_path={$tarPath}",
"sha256_zip={$sha256Zip}",
"sha256_tar={$sha256Tar}",
"type_prefix={$typePrefix}",
"ext_element={$extElement}",
];
if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
$this->log('INFO', "Exported " . count($lines) . " fields to GITHUB_OUTPUT");
} else {
foreach ($lines as $line) {
echo "{$line}\n";
}
}
}
return 0;
}
/**
* Recursively add directory contents to a ZipArchive.
*/
private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $prefix, array $excludes): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
$filePath = $file->getPathname();
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
// Check excludes
$basename = basename($filePath);
$skip = false;
foreach ($excludes as $pattern) {
if (fnmatch($pattern, $basename)) {
$skip = true;
break;
}
}
if ($skip) {
continue;
}
// Normalize path separators for ZIP
$relativePath = str_replace('\\', '/', $relativePath);
if ($file->isDir()) {
$zip->addEmptyDir($relativePath);
} else {
$zip->addFile($filePath, $relativePath);
}
} }
} }
} }
$app = new PackageBuildCli();
exit($app->execute());
+186 -26
View File
@@ -1,40 +1,200 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/platform_detect.php * PATH: /cli/platform_detect.php
* BRIEF: Detect platform from .mokostandards file — outputs platform string * VERSION: 09.38.00
* BRIEF: Auto-detect repository platform type and optionally update manifest
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; use MokoCli\CliFramework;
class PlatformDetectCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Auto-detect repository platform type and optionally update manifest');
$this->addArgument('--path', 'Local repo path to scan (default: .)', '.');
$this->addArgument('--token', 'Gitea API token for updating manifest', '');
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
$this->addArgument('--owner', 'Repo owner for API update', '');
$this->addArgument('--repo', 'Repo name for API update', '');
$this->addArgument('--update', 'Update manifest.platform via API (flag)', 'false');
$this->addArgument('--github-output', 'Append platform=xxx to $GITHUB_OUTPUT (flag)', 'false');
}
protected function run(): int
{
$path = $this->getArgument('--path');
$root = realpath($path) ?: $path;
$token = $this->getArgument('--token');
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$owner = $this->getArgument('--owner');
$repo = $this->getArgument('--repo');
$doUpdate = $this->isFlagSet('--update');
$githubOutput = $this->isFlagSet('--github-output');
$platform = $this->detectPlatform($root);
$this->log('INFO', "Detected platform: {$platform}");
echo $platform . "\n";
// Append to $GITHUB_OUTPUT if requested
if ($githubOutput) {
$outputFile = getenv('GITHUB_OUTPUT');
if ($outputFile !== false && $outputFile !== '') {
file_put_contents($outputFile, "platform={$platform}\n", FILE_APPEND);
$this->log('INFO', "Appended platform={$platform} to \$GITHUB_OUTPUT");
} else {
$this->log('WARN', '$GITHUB_OUTPUT is not set; skipping output append.');
}
}
// Update manifest via API if requested
if ($doUpdate) {
if ($token === '' || $owner === '' || $repo === '') {
$this->log('ERROR', '--update requires --token, --owner, and --repo.');
return 1;
}
if ($this->dryRun) {
$this->log('INFO', "[DRY RUN] Would update manifest.platform to \"{$platform}\" "
. "for {$owner}/{$repo}.");
return 0;
}
$this->log('INFO', "Updating manifest.platform for {$owner}/{$repo} to \"{$platform}\"...");
$response = $this->apiRequest(
$giteaUrl,
$token,
'PATCH',
"/api/v1/repos/{$owner}/{$repo}/metadata",
json_encode(['platform' => $platform])
);
if ($response['code'] >= 200 && $response['code'] < 300) {
$this->log('INFO', "Manifest updated successfully (HTTP {$response['code']}).");
} else {
$this->log('ERROR', "Failed to update manifest (HTTP {$response['code']}): "
. $response['body']);
return 1;
}
}
return 0;
}
private function detectPlatform(string $root): string
{
// 1. Joomla — has pkg_*.xml or Joomla-style extension manifest
$joomlaIndicators = array_merge(
glob("{$root}/source/pkg_*.xml") ?: [],
glob("{$root}/pkg_*.xml") ?: [],
glob("{$root}/source/packages/*/services/provider.php") ?: [],
glob("{$root}/**/templateDetails.xml") ?: [],
);
if (!empty($joomlaIndicators)) {
return 'joomla';
}
// 2. Dolibarr — has mod*.class.php or dolibarr module descriptor
$doliIndicators = array_merge(
glob("{$root}/core/modules/mod*.class.php") ?: [],
glob("{$root}/class/*.class.php") ?: [],
);
if (!empty($doliIndicators) && file_exists("{$root}/langs")) {
return 'dolibarr';
}
// 3. Go — has go.mod
if (file_exists("{$root}/go.mod")) {
return 'go';
}
// 4. MCP — has package.json with mcp-related content or dist/index.js pattern
if (file_exists("{$root}/package.json")) {
$pkg = json_decode(file_get_contents("{$root}/package.json"), true);
$name = $pkg['name'] ?? '';
if (str_contains($name, 'mcp') || isset($pkg['dependencies']['@modelcontextprotocol/sdk'])) {
return 'mcp';
}
}
// 5. Platform — is mokocli itself or org-config
$repoName = basename($root);
if (in_array($repoName, ['mokocli', 'mokogitea-org-config'])) {
return 'platform';
}
// 6. Default
return 'generic';
}
private function isFlagSet(string $flag): bool
{
$value = $this->getArgument($flag);
return $value === 'true' || $value === '1' || $value === 'yes';
}
private function apiRequest(
string $giteaUrl,
string $token,
string $method,
string $endpoint,
?string $body = null
): array {
$url = $giteaUrl . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$token}",
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo(
$ch,
CURLINFO_HTTP_CODE
);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return [
'code' => 0,
'body' => "cURL error: {$error}",
];
}
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
} }
$root = realpath($path) ?: $path; $app = new PlatformDetectCli();
// Check .github/.mokostandards first, fallback to root exit($app->execute());
$file = "{$root}/.github/.mokostandards";
if (!file_exists($file)) {
$file = "{$root}/.mokostandards";
}
if (!file_exists($file)) {
echo "unknown\n";
exit(0);
}
$content = file_get_contents($file);
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
echo trim($m[1], " \t\n\r\"'") . "\n";
} else {
echo "unknown\n";
}
exit(0);
+174 -153
View File
@@ -1,171 +1,192 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release.php * PATH: /cli/release.php
* BRIEF: Automate the MokoStandards version branch release flow * BRIEF: Automate the mokocli version branch release flow
*
* USAGE
* php cli/release.php # Release current version
* php cli/release.php --bump minor # Bump minor, then release
* php cli/release.php --bump major # Bump major, then release
* php cli/release.php --dry-run # Preview without changes
*/ */
declare(strict_types=1); declare(strict_types=1);
$dryRun = in_array('--dry-run', $argv); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$bumpType = null;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--bump' && isset($argv[$i + 1])) {
$bumpType = $argv[$i + 1]; // patch | minor | major class ReleaseCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Automate the mokocli version branch release flow');
$this->addArgument('--bump', 'Bump type: patch, minor, or major', '');
} }
}
$repoRoot = dirname(__DIR__, 2); protected function run(): int
$syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php"; {
// Check both workflow directories for the bulk-repo-sync workflow $bumpType = $this->getArgument('--bump');
$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml") if (empty($bumpType)) {
? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml" $bumpType = null;
: "{$repoRoot}/.github/workflows/bulk-repo-sync.yml";
$cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template";
// ── Step 1: Read current version ────────────────────────────────────────
$readme = "{$repoRoot}/README.md";
$content = file_get_contents($readme);
if (!preg_match('/^\s*VERSION:\s*(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) {
fwrite(STDERR, "No VERSION found in README.md\n");
exit(1);
}
$major = (int)$m[1];
$minor = (int)$m[2];
$patch = (int)$m[3];
$currentVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
// ── Step 2: Bump version if requested ───────────────────────────────────
if ($bumpType) {
switch ($bumpType) {
case 'major': $major++; $minor = 0; $patch = 0; break;
case 'minor': $minor++; $patch = 0; break;
case 'patch': $patch++; break;
default:
fwrite(STDERR, "Invalid bump type: {$bumpType} (use patch/minor/major)\n");
exit(1);
}
$newVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
echo "Bumping: {$currentVersion}{$newVersion}\n";
if (!$dryRun) {
// Update README.md
$content = preg_replace(
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
'${1}' . $newVersion,
$content,
1
);
file_put_contents($readme, $content);
// Propagate to all files
echo "Propagating version to all files...\n";
passthru("php {$repoRoot}/api/maintenance/update_version_from_readme.php --path {$repoRoot}");
}
$currentVersion = $newVersion;
} else {
echo "Version: {$currentVersion}\n";
}
// Derive major.minor for branch naming (patches update existing branch)
$versionParts = explode('.', $currentVersion);
$minorVersion = $versionParts[0] . '.' . $versionParts[1];
$branch = "version/{$minorVersion}";
// ── Step 3: Update STANDARDS_VERSION + STANDARDS_MINOR constants ────────
echo "Updating STANDARDS_VERSION → {$currentVersion}\n";
echo "Updating STANDARDS_MINOR → {$minorVersion}\n";
if (!$dryRun) {
$syncContent = file_get_contents($syncFile);
$syncContent = preg_replace(
"/STANDARDS_VERSION\s*=\s*'[^']+'/",
"STANDARDS_VERSION = '{$currentVersion}'",
$syncContent
);
$syncContent = preg_replace(
"/STANDARDS_MINOR\s*=\s*'[^']+'/",
"STANDARDS_MINOR = '{$minorVersion}'",
$syncContent
);
file_put_contents($syncFile, $syncContent);
}
// ── Step 4: Update bulk-repo-sync.yml checkout ref ──────────────────────
echo "Updating bulk-repo-sync.yml → {$branch}\n";
if (!$dryRun) {
$bulkContent = file_get_contents($bulkSyncFile);
$bulkContent = preg_replace(
'/ref:\s*version\/[\d.]+/',
"ref: {$branch}",
$bulkContent
);
file_put_contents($bulkSyncFile, $bulkContent);
}
// ── Step 5: Update repository-cleanup.yml current branch ────────────────
echo "Updating repository-cleanup.yml → chore/sync-mokostandards-v{$minorVersion}\n";
if (!$dryRun) {
$cleanupContent = file_get_contents($cleanupFile);
$cleanupContent = preg_replace(
'/CURRENT="chore\/sync-mokostandards-v[^"]*"/',
"CURRENT=\"chore/sync-mokostandards-v{$minorVersion}\"",
$cleanupContent
);
file_put_contents($cleanupFile, $cleanupContent);
}
// ── Step 6: Commit changes ──────────────────────────────────────────────
if (!$dryRun) {
echo "Committing...\n";
passthru("cd {$repoRoot} && git add -A && git commit -m \"chore(release): prepare {$currentVersion} release [skip ci]\"");
passthru("cd {$repoRoot} && git pull --rebase 2>/dev/null; git push");
}
// ── Step 7: Create or update version branch ─────────────────────────────
$isPatch = ($versionParts[2] ?? '00') !== '00';
if ($isPatch) {
echo "Updating version branch: {$branch} (patch update)\n";
if (!$dryRun) {
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
}
} else {
echo "Creating version branch: {$branch} (minor release)\n";
if (!$dryRun) {
$exitCode = 0;
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} 2>&1", $exitCode);
if ($exitCode !== 0) {
echo "Branch {$branch} already exists — force updating\n";
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
} }
$repoRoot = dirname(__DIR__, 2);
$syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php";
// Check both workflow directories for the bulk-repo-sync workflow
$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml")
? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml"
: "{$repoRoot}/.github/workflows/bulk-repo-sync.yml";
$cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template";
// -- Step 1: Read current version --
$readme = "{$repoRoot}/README.md";
$content = file_get_contents($readme);
if (!preg_match('/^\s*VERSION:\s*(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) {
$this->log('ERROR', 'No VERSION found in README.md');
return 1;
}
$major = (int)$m[1];
$minor = (int)$m[2];
$patch = (int)$m[3];
$currentVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
// -- Step 2: Bump version if requested --
if ($bumpType) {
switch ($bumpType) {
case 'major':
$major++;
$minor = 0;
$patch = 0;
break;
case 'minor':
$minor++;
$patch = 0;
break;
case 'patch':
$patch++;
break;
default:
$this->log('ERROR', "Invalid bump type: {$bumpType} (use patch/minor/major)");
return 1;
}
$newVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
echo "Bumping: {$currentVersion} -> {$newVersion}\n";
if (!$this->dryRun) {
// Update README.md
$content = preg_replace(
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
'${1}' . $newVersion,
$content,
1
);
file_put_contents($readme, $content);
// Propagate to all files
echo "Propagating version to all files...\n";
passthru("php {$repoRoot}/api/maintenance/update_version_from_readme.php --path {$repoRoot}");
}
$currentVersion = $newVersion;
} else {
echo "Version: {$currentVersion}\n";
}
// Derive major.minor for branch naming (patches update existing branch)
$versionParts = explode('.', $currentVersion);
$minorVersion = $versionParts[0] . '.' . $versionParts[1];
$branch = "version/{$minorVersion}";
// -- Step 3: Update STANDARDS_VERSION + STANDARDS_MINOR constants --
echo "Updating STANDARDS_VERSION -> {$currentVersion}\n";
echo "Updating STANDARDS_MINOR -> {$minorVersion}\n";
if (!$this->dryRun) {
$syncContent = file_get_contents($syncFile);
$syncContent = preg_replace(
"/STANDARDS_VERSION\s*=\s*'[^']+'/",
"STANDARDS_VERSION = '{$currentVersion}'",
$syncContent
);
$syncContent = preg_replace(
"/STANDARDS_MINOR\s*=\s*'[^']+'/",
"STANDARDS_MINOR = '{$minorVersion}'",
$syncContent
);
file_put_contents($syncFile, $syncContent);
}
// -- Step 4: Update bulk-repo-sync.yml checkout ref --
echo "Updating bulk-repo-sync.yml -> {$branch}\n";
if (!$this->dryRun) {
$bulkContent = file_get_contents($bulkSyncFile);
$bulkContent = preg_replace(
'/ref:\s*version\/[\d.]+/',
"ref: {$branch}",
$bulkContent
);
file_put_contents($bulkSyncFile, $bulkContent);
}
// -- Step 5: Update repository-cleanup.yml current branch --
echo "Updating repository-cleanup.yml -> chore/sync-mokostandards-v{$minorVersion}\n";
if (!$this->dryRun) {
$cleanupContent = file_get_contents($cleanupFile);
$cleanupContent = preg_replace(
'/CURRENT="chore\/sync-mokostandards-v[^"]*"/',
"CURRENT=\"chore/sync-mokostandards-v{$minorVersion}\"",
$cleanupContent
);
file_put_contents($cleanupFile, $cleanupContent);
}
// -- Step 6: Commit changes --
if (!$this->dryRun) {
echo "Committing...\n";
passthru("cd {$repoRoot} && git add -A && git commit -m \"chore(release): prepare {$currentVersion} release [skip ci]\"");
passthru("cd {$repoRoot} && git pull --rebase 2>/dev/null; git push");
}
// -- Step 7: Create or update version branch --
$isPatch = ($versionParts[2] ?? '00') !== '00';
if ($isPatch) {
echo "Updating version branch: {$branch} (patch update)\n";
if (!$this->dryRun) {
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
}
} else {
echo "Creating version branch: {$branch} (minor release)\n";
if (!$this->dryRun) {
$exitCode = 0;
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} 2>&1", $exitCode);
if ($exitCode !== 0) {
echo "Branch {$branch} already exists — force updating\n";
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
}
}
}
// -- Step 8: Create git tag (never overwrite existing) --
$tag = "v{$currentVersion}";
echo "Creating tag {$tag}\n";
if (!$this->dryRun) {
$exitCode = 0;
passthru("cd {$repoRoot} && git tag {$tag} 2>/dev/null && git push origin {$tag} 2>/dev/null", $exitCode);
if ($exitCode !== 0) {
echo "Tag {$tag} already exists — skipping\n";
}
}
echo "\nRelease {$currentVersion} complete\n";
echo " Branch: {$branch}\n";
echo " Tag: {$tag}\n";
echo " Next: run bulk sync to push to all repos\n";
return 0;
} }
} }
// ── Step 8: Create git tag (never overwrite existing) ─────────────────── $app = new ReleaseCli();
$tag = "v{$currentVersion}"; exit($app->execute());
echo "Creating tag {$tag}\n";
if (!$dryRun) {
$exitCode = 0;
passthru("cd {$repoRoot} && git tag {$tag} 2>/dev/null && git push origin {$tag} 2>/dev/null", $exitCode);
if ($exitCode !== 0) {
echo "⚠️ Tag {$tag} already exists — skipping\n";
}
}
echo "\n✅ Release {$currentVersion} complete\n";
echo " Branch: {$branch}\n";
echo " Tag: {$tag}\n";
echo " Next: run bulk sync to push to all repos\n";
+142 -136
View File
@@ -1,152 +1,158 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_body_update.php * PATH: /cli/release_body_update.php
* BRIEF: Update Gitea release body with changelog extract and checksums * BRIEF: Update Gitea release body with changelog extract and checksums
*
* Usage:
* php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL
* php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL --zip-name pkg.zip --zip-sha abc123
*
* Options:
* --path Repo root for CHANGELOG.md (default: .)
* --version Version string (required)
* --release-tag Gitea release tag (required)
* --token Gitea API token (required)
* --api-base Gitea API base URL (required)
* --zip-name ZIP filename for checksum table
* --tar-name tar.gz filename for checksum table
* --zip-sha SHA256 of ZIP
* --tar-sha SHA256 of tar.gz
* --output-summary Write to $GITHUB_STEP_SUMMARY
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$releaseTag = null;
$token = null;
$apiBase = null;
$zipName = null;
$tarName = null;
$zipSha = null;
$tarSha = null;
$outputSummary = false;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; class ReleaseBodyUpdateCli extends CliFramework
if ($arg === '--release-tag' && isset($argv[$i + 1])) $releaseTag = $argv[$i + 1]; {
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; protected function configure(): void
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1]; {
if ($arg === '--zip-name' && isset($argv[$i + 1])) $zipName = $argv[$i + 1]; $this->setDescription('Update Gitea release body with changelog extract and checksums');
if ($arg === '--tar-name' && isset($argv[$i + 1])) $tarName = $argv[$i + 1]; $this->addArgument('--path', 'Repo root for CHANGELOG.md', '.');
if ($arg === '--zip-sha' && isset($argv[$i + 1])) $zipSha = $argv[$i + 1]; $this->addArgument('--version', 'Version string', '');
if ($arg === '--tar-sha' && isset($argv[$i + 1])) $tarSha = $argv[$i + 1]; $this->addArgument('--release-tag', 'Gitea release tag', '');
if ($arg === '--output-summary') $outputSummary = true; $this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--api-base', 'Gitea API base URL', '');
$this->addArgument('--zip-name', 'ZIP filename for checksum table', '');
$this->addArgument('--tar-name', 'tar.gz filename for checksum table', '');
$this->addArgument('--zip-sha', 'SHA256 of ZIP', '');
$this->addArgument('--tar-sha', 'SHA256 of tar.gz', '');
$this->addArgument('--output-summary', 'Write to $GITHUB_STEP_SUMMARY', false);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$releaseTag = $this->getArgument('--release-tag');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$zipName = $this->getArgument('--zip-name');
$tarName = $this->getArgument('--tar-name');
$zipSha = $this->getArgument('--zip-sha');
$tarSha = $this->getArgument('--tar-sha');
$outputSummary = $this->getArgument('--output-summary');
if (empty($token)) {
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: '';
}
if (empty($version) || empty($releaseTag) || empty($token) || empty($apiBase)) {
$this->log('ERROR', 'Usage: release_body_update.php --version VER --release-tag TAG --token TOKEN --api-base URL');
return 1;
}
$root = realpath($path) ?: $path;
// Extract changelog section for this version
$changelog = '';
$clFile = "{$root}/CHANGELOG.md";
if (file_exists($clFile)) {
$lines = file($clFile, FILE_IGNORE_NEW_LINES);
$capturing = false;
$clLines = [];
foreach ($lines as $line) {
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
$capturing = true;
continue;
}
if ($capturing && preg_match('/^## /', $line)) {
break;
}
if ($capturing) {
$clLines[] = $line;
}
}
$changelog = trim(implode("\n", $clLines));
}
// Build release body
$body = "## {$version} (" . date('Y-m-d') . ")\n\n";
if (!empty($changelog)) {
$body .= "{$changelog}\n\n";
}
if (!empty($zipSha) || !empty($tarSha)) {
$body .= "---\n\n### Checksums\n\n| File | SHA-256 |\n|------|--------|\n";
if (!empty($zipName) && !empty($zipSha)) {
$body .= "| `{$zipName}` | `{$zipSha}` |\n";
}
if (!empty($tarName) && !empty($tarSha)) {
$body .= "| `{$tarName}` | `{$tarSha}` |\n";
}
}
// Get release ID by tag
$ch = curl_init("{$apiBase}/releases/tags/{$releaseTag}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || empty($response)) {
$this->log('ERROR', "Failed to get release for tag '{$releaseTag}' (HTTP {$httpCode})");
return 1;
}
$release = json_decode($response, true);
$releaseId = $release['id'] ?? null;
if ($releaseId === null) {
$this->log('ERROR', "No release ID found for tag '{$releaseTag}'");
return 1;
}
// PATCH release body
$payload = json_encode(['body' => $body]);
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$this->log('ERROR', "Failed to update release body (HTTP {$httpCode})");
return 1;
}
echo "Release body updated for {$releaseTag} (release #{$releaseId})\n";
if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) {
file_put_contents($summaryFile, "Release body updated with changelog + checksums\n", FILE_APPEND);
}
}
return 0;
}
} }
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; $app = new ReleaseBodyUpdateCli();
exit($app->execute());
if ($version === null || $releaseTag === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: release_body_update.php --version VER --release-tag TAG --token TOKEN --api-base URL\n");
exit(1);
}
$root = realpath($path) ?: $path;
// Extract changelog section for this version
$changelog = '';
$clFile = "{$root}/CHANGELOG.md";
if (file_exists($clFile)) {
$lines = file($clFile, FILE_IGNORE_NEW_LINES);
$capturing = false;
$clLines = [];
foreach ($lines as $line) {
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
$capturing = true;
continue;
}
if ($capturing && preg_match('/^## /', $line)) break;
if ($capturing) $clLines[] = $line;
}
$changelog = trim(implode("\n", $clLines));
}
// Build release body
$body = "## {$version} (" . date('Y-m-d') . ")\n\n";
if (!empty($changelog)) {
$body .= "{$changelog}\n\n";
}
if ($zipSha !== null || $tarSha !== null) {
$body .= "---\n\n### Checksums\n\n| File | SHA-256 |\n|------|--------|\n";
if ($zipName !== null && $zipSha !== null) {
$body .= "| `{$zipName}` | `{$zipSha}` |\n";
}
if ($tarName !== null && $tarSha !== null) {
$body .= "| `{$tarName}` | `{$tarSha}` |\n";
}
}
// Get release ID by tag
$ch = curl_init("{$apiBase}/releases/tags/{$releaseTag}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || empty($response)) {
fwrite(STDERR, "Failed to get release for tag '{$releaseTag}' (HTTP {$httpCode})\n");
exit(1);
}
$release = json_decode($response, true);
$releaseId = $release['id'] ?? null;
if ($releaseId === null) {
fwrite(STDERR, "No release ID found for tag '{$releaseTag}'\n");
exit(1);
}
// PATCH release body
$payload = json_encode(['body' => $body]);
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
fwrite(STDERR, "Failed to update release body (HTTP {$httpCode})\n");
exit(1);
}
echo "Release body updated for {$releaseTag} (release #{$releaseId})\n";
if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) {
file_put_contents($summaryFile, "Release body updated with changelog + checksums\n", FILE_APPEND);
}
}
exit(0);
+333 -7
View File
@@ -1,17 +1,343 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_cascade.php * PATH: /cli/release_cascade.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent. * BRIEF: Cascade release zip to all lower stability channels
*/ */
echo "release_cascade.php: No-op (cascade behavior removed — each stream is independent)\n"; declare(strict_types=1);
exit(0);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class ReleaseCascadeCli extends CliFramework
{
/** Channel hierarchy: highest stability first. */
private const CHANNELS = ['stable', 'release-candidate', 'beta', 'alpha', 'development'];
/** Map stability input names to canonical tag names. */
private const TAG_MAP = [
'stable' => 'stable',
'release-candidate' => 'release-candidate',
'rc' => 'release-candidate',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'development',
'dev' => 'development',
];
protected function configure(): void
{
$this->setDescription('Cascade release zip to all lower stability channels');
$this->addArgument('--stability', 'Source stability channel (required)', '');
$this->addArgument('--token', 'Gitea API token (required)', '');
$this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
}
protected function run(): int
{
$stability = strtolower($this->getArgument('--stability'));
$token = $this->getArgument('--token');
$apiBase = rtrim($this->getArgument('--api-base'), '/');
if ($token === '') {
$envToken = getenv('MOKOGITEA_TOKEN');
if ($envToken === false || $envToken === '') {
$envToken = getenv('GITEA_TOKEN');
}
if ($envToken !== false && $envToken !== '') {
$token = $envToken;
}
}
if ($stability === '' || $token === '' || $apiBase === '') {
$this->log('ERROR', 'Usage: release_cascade.php --stability CHANNEL --token TOKEN --api-base URL');
return 1;
}
$sourceTag = self::TAG_MAP[$stability] ?? null;
if ($sourceTag === null) {
$this->log('ERROR', "Unknown stability: {$stability}");
return 1;
}
// Find lower channels to cascade to
$lowerChannels = $this->getLowerChannels($sourceTag);
if (count($lowerChannels) === 0) {
$this->log('INFO', "No lower channels for '{$stability}' — nothing to cascade.");
return 0;
}
$this->log('INFO', "Cascading from '{$sourceTag}' to: " . implode(', ', $lowerChannels));
if ($this->dryRun) {
$this->log('INFO', '[DRY RUN] No changes will be made.');
}
// 1. Get source release
$sourceRelease = $this->giteaApi("{$apiBase}/releases/tags/{$sourceTag}", $token);
if ($sourceRelease === null) {
$this->log('WARN', "No release found at tag '{$sourceTag}' — nothing to cascade.");
return 0;
}
$sourceVersion = $sourceRelease['name'] ?? $sourceTag;
$sourceBody = $sourceRelease['body'] ?? '';
$sourceAssets = $sourceRelease['assets'] ?? [];
// Find zip assets (exclude .sha256 sidecars)
$zipAssets = array_filter($sourceAssets, function (array $asset): bool {
$name = strtolower($asset['name'] ?? '');
return str_ends_with($name, '.zip') && !str_ends_with($name, '.sha256');
});
// Also grab sha256 sidecars
$sha256Assets = array_filter($sourceAssets, function (array $asset): bool {
return str_ends_with(strtolower($asset['name'] ?? ''), '.zip.sha256');
});
if (count($zipAssets) === 0) {
$this->log('WARN', "Source release '{$sourceTag}' has no zip assets — nothing to cascade.");
return 0;
}
$this->log('INFO', "Source: {$sourceVersion}" . count($zipAssets) . " zip(s)");
echo "\n";
// 2. Download source assets to temp files
$downloads = [];
foreach (array_merge($zipAssets, $sha256Assets) as $asset) {
$url = $asset['browser_download_url'] ?? '';
if ($url === '') {
continue;
}
$tmpFile = tempnam(sys_get_temp_dir(), 'cascade_');
if ($this->downloadFile($url, $token, $tmpFile)) {
$downloads[] = ['name' => $asset['name'], 'path' => $tmpFile];
$this->log('INFO', "Downloaded: {$asset['name']}");
} else {
$this->log('ERROR', "Failed to download: {$asset['name']}");
}
}
if (count($downloads) === 0) {
$this->log('ERROR', 'Could not download any source assets.');
return 1;
}
// 3. Cascade to each lower channel
$errors = 0;
foreach ($lowerChannels as $targetTag) {
echo "\n";
$result = $this->cascadeToChannel(
$apiBase, $token, $targetTag,
$sourceVersion, $sourceBody, $downloads
);
if (!$result) {
$errors++;
}
}
// 4. Cleanup temp files
foreach ($downloads as $dl) {
@unlink($dl['path']);
}
echo "\n";
$this->log('INFO', "Cascade complete. " . (count($lowerChannels) - $errors)
. "/" . count($lowerChannels) . " channels updated.");
return $errors > 0 ? 1 : 0;
}
/**
* Cascade assets to a single target channel.
*/
private function cascadeToChannel(
string $apiBase,
string $token,
string $targetTag,
string $sourceVersion,
string $sourceBody,
array $downloads
): bool {
$this->log('INFO', "{$targetTag}");
if ($this->dryRun) {
$this->log('INFO', " [DRY RUN] Would cascade to {$targetTag}");
return true;
}
// Find existing release at target tag
$existing = $this->giteaApi("{$apiBase}/releases/tags/{$targetTag}", $token);
if ($existing !== null && !empty($existing['id'])) {
$releaseId = (int) $existing['id'];
// Delete existing assets
$existingAssets = $existing['assets'] ?? [];
foreach ($existingAssets as $asset) {
$assetId = $asset['id'] ?? 0;
if ($assetId > 0) {
$this->giteaApi(
"{$apiBase}/releases/{$releaseId}/assets/{$assetId}",
$token, 'DELETE'
);
}
}
// Update release metadata
$updatePayload = json_encode([
'name' => $sourceVersion,
'body' => $sourceBody,
]);
$this->giteaApi(
"{$apiBase}/releases/{$releaseId}",
$token, 'PATCH', $updatePayload
);
$this->log('INFO', " Updated release metadata (id: {$releaseId})");
} else {
// Create new release at target tag
// Use the source release's target commitish so the tag points to the same commit
$createPayload = json_encode([
'tag_name' => $targetTag,
'target_commitish' => 'main',
'name' => $sourceVersion,
'body' => $sourceBody,
'prerelease' => ($targetTag !== 'stable'),
]);
$newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $createPayload);
if ($newRelease === null || empty($newRelease['id'])) {
$this->log('ERROR', " Failed to create release at tag '{$targetTag}'");
return false;
}
$releaseId = (int) $newRelease['id'];
$this->log('INFO', " Created release (id: {$releaseId})");
}
// Upload assets
foreach ($downloads as $dl) {
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . rawurlencode($dl['name']);
$success = $this->uploadAsset($uploadUrl, $token, $dl['path'], $dl['name']);
if ($success) {
$this->log('INFO', " Uploaded: {$dl['name']}");
} else {
$this->log('ERROR', " Failed to upload: {$dl['name']}");
}
}
return true;
}
/**
* Get all channels below the given source channel.
*/
private function getLowerChannels(string $sourceTag): array
{
$idx = array_search($sourceTag, self::CHANNELS, true);
if ($idx === false) {
return [];
}
return array_slice(self::CHANNELS, $idx + 1);
}
/**
* Download a file via HTTP.
*/
private function downloadFile(string $url, string $token, string $destPath): bool
{
$ch = curl_init($url);
if ($ch === false) {
return false;
}
$fp = fopen($destPath, 'wb');
if ($fp === false) {
return false;
}
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_FILE => $fp,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
return $code >= 200 && $code < 300;
}
/**
* Upload a file as a release asset via multipart form.
*/
private function uploadAsset(string $url, string $token, string $filePath, string $fileName): bool
{
$ch = curl_init($url);
if ($ch === false) {
return false;
}
$cfile = new CURLFile($filePath, 'application/octet-stream', $fileName);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => ['attachment' => $cfile],
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 120,
]);
$response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $code >= 200 && $code < 300;
}
/**
* Make an HTTP request to the Gitea API.
*/
private function giteaApi(
string $url,
string $token,
string $method = 'GET',
?string $body = null
): ?array {
$ch = curl_init($url);
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) {
return null;
}
$decoded = json_decode($response, true);
return is_array($decoded) ? $decoded : null;
}
}
$app = new ReleaseCascadeCli();
exit($app->execute());
+277 -306
View File
@@ -6,333 +6,304 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_create.php * PATH: /cli/release_create.php
* BRIEF: Create or overwrite a Gitea release with proper naming * BRIEF: Create or overwrite a Gitea release with proper naming
*
* Usage:
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL
* php release_create.php --version 09.01.00 --tag development --token TOKEN --api-base URL --prerelease
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL --path . --repo MyRepo
*
* Replaces the inline bash in auto-release.yml Step 7b.
* Detects extension metadata from manifest, builds a proper release name,
* generates release notes, and creates (or overwrites) a Gitea release.
*/ */
declare(strict_types=1); declare(strict_types=1);
// ── Argument parsing ──────────────────────────────────────────────────────── require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.'; use MokoCli\{CliFramework, SourceResolver};
$version = null;
$tag = null;
$token = null;
$apiBase = null;
$branch = 'main';
$repoName = '';
$prerelease = false;
foreach ($argv as $i => $arg) { class ReleaseCreateCli extends CliFramework
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--tag' && isset($argv[$i + 1])) {
$tag = $argv[$i + 1];
}
if ($arg === '--token' && isset($argv[$i + 1])) {
$token = $argv[$i + 1];
}
if ($arg === '--api-base' && isset($argv[$i + 1])) {
$apiBase = $argv[$i + 1];
}
if ($arg === '--branch' && isset($argv[$i + 1])) {
$branch = $argv[$i + 1];
}
if ($arg === '--repo' && isset($argv[$i + 1])) {
$repoName = $argv[$i + 1];
}
if ($arg === '--prerelease') {
$prerelease = true;
}
}
// Allow token from environment
if ($token === null) {
$envToken = getenv('MOKOGITEA_TOKEN');
if ($envToken === false || $envToken === '') {
$envToken = getenv('GITEA_TOKEN');
}
if ($envToken !== false && $envToken !== '') {
$token = $envToken;
}
}
if ($version === null || $tag === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]\n");
fwrite(STDERR, " --path . Repo root for manifest detection (default: .)\n");
fwrite(STDERR, " --branch main Target commitish (default: main)\n");
fwrite(STDERR, " --repo REPO Repo name for fallback element detection\n");
fwrite(STDERR, " --prerelease Mark release as prerelease\n");
fwrite(STDERR, " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var\n");
exit(1);
}
// ── Helper: Gitea API request ───────────────────────────────────────────────
/**
* Send a request to the Gitea API.
*
* @param string $url Full API URL
* @param string $token Authorization token
* @param string $method HTTP method (GET, POST, DELETE, etc.)
* @param string|null $body JSON request body
*
* @return array<string, mixed>|null Decoded response or null on failure
*/
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{ {
$ch = curl_init($url); protected function configure(): void
if ($ch === false) { {
return null; $this->setDescription('Create or overwrite a Gitea release with proper naming');
} $this->addArgument('--path', 'Repo root for manifest detection (default: .)', '.');
curl_setopt_array($ch, [ $this->addArgument('--version', 'Version string (required)', '');
CURLOPT_RETURNTRANSFER => true, $this->addArgument('--tag', 'Release tag name (required)', '');
CURLOPT_HTTPHEADER => [ $this->addArgument('--token', 'Gitea API token (required)', '');
"Authorization: token {$token}", $this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
'Content-Type: application/json', $this->addArgument('--branch', 'Target commitish (default: main)', 'main');
], $this->addArgument('--repo', 'Repo name for fallback element detection', '');
CURLOPT_TIMEOUT => 30, $this->addArgument('--prerelease', 'Mark release as prerelease', false);
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) {
return null;
} }
$decoded = json_decode($response, true); protected function run(): int
return is_array($decoded) ? $decoded : null; {
} $path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$tag = $this->getArgument('--tag');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$branch = $this->getArgument('--branch');
$repoName = $this->getArgument('--repo');
$prerelease = (bool) $this->getArgument('--prerelease');
// ── Detect element metadata ───────────────────────────────────────────────── // Allow token from environment
if ($token === '') {
$root = realpath($path) ?: $path; $envToken = getenv('MOKOGITEA_TOKEN');
if ($envToken === false || $envToken === '') {
$extElement = ''; $envToken = getenv('GITEA_TOKEN');
$extType = ''; }
$extFolder = ''; if ($envToken !== false && $envToken !== '') {
$extName = ''; $token = $envToken;
$typePrefix = '';
// Detect platform and display name from manifest.xml
$platform = 'generic';
$prettyName = '';
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml);
if ($content !== false) {
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$platform = trim($pm[1]);
}
// <display-name> is the human-friendly name; <name> is the element/repo name
if (preg_match('/<display-name>([^<]+)<\/display-name>/', $content, $dn)) {
$prettyName = trim($dn[1]);
} elseif (preg_match('/<name>([^<]+)<\/name>/', $content, $nm)) {
$prettyName = trim($nm[1]);
}
}
}
// Find extension manifest (Joomla XML)
$extManifest = null;
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $file) {
$c = file_get_contents($file);
if ($c !== false && strpos($c, '<extension') !== false) {
$extManifest = $file;
break;
}
}
// Find Dolibarr module file
$modFile = null;
$modFiles = array_merge(
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($modFiles as $file) {
$c = file_get_contents($file);
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
$modFile = $file;
break;
}
}
// Extract metadata based on platform
switch (true) {
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest);
if ($xml === false) {
break;
}
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
// Element name: <element>, plugin= attribute, <packagename>, or filename
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
$extElement = $pm2[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1];
}
if (empty($extElement)) {
$extElement = strtolower(basename($extManifest, '.xml'));
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
} }
} }
// Human-readable name if ($version === '' || $tag === '' || $token === '' || $apiBase === '') {
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) { $this->log('ERROR', "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]");
$extName = trim($nm[1]); $this->log('ERROR', " --path . Repo root for manifest detection (default: .)");
$this->log('ERROR', " --branch main Target commitish (default: main)");
$this->log('ERROR', " --repo REPO Repo name for fallback element detection");
$this->log('ERROR', " --prerelease Mark release as prerelease");
$this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var");
return 1;
} }
break;
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: // ── Detect element metadata ─────────────────────────────────────────────
$extType = 'dolibarr-module';
$modBasename = basename($modFile, '.class.php');
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename);
$modContent = file_get_contents($modFile); $root = realpath($path) ?: $path;
if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) {
$extName = $nm2[1]; $extElement = '';
$extType = '';
$extFolder = '';
$extName = '';
$typePrefix = '';
// Detect platform and display name from manifest.xml
$platform = 'generic';
$prettyName = '';
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml);
if ($content !== false) {
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$platform = trim($pm[1]);
}
if (preg_match('/<display-name>([^<]+)<\/display-name>/', $content, $dn)) {
$prettyName = trim($dn[1]);
} elseif (preg_match('/<name>([^<]+)<\/name>/', $content, $nm)) {
$prettyName = trim($nm[1]);
}
}
} }
break;
default: // Find extension manifest (Joomla XML)
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); $extManifest = null;
$extType = 'generic'; $manifestFiles = array_merge(
break; SourceResolver::globSource($root, 'pkg_*.xml'),
} SourceResolver::globSource($root, '*.xml'),
glob("{$root}/*.xml") ?: []
// Strip existing type prefix from element to prevent duplication );
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement; foreach ($manifestFiles as $file) {
$c = file_get_contents($file);
// Compute type prefix if ($c !== false && strpos($c, '<extension') !== false) {
switch ($extType) { $extManifest = $file;
case 'plugin': break;
$typePrefix = "plg_{$extFolder}_"; }
break;
case 'module':
$typePrefix = 'mod_';
break;
case 'component':
$typePrefix = 'com_';
break;
case 'template':
$typePrefix = 'tpl_';
break;
case 'library':
$typePrefix = 'lib_';
break;
case 'package':
$typePrefix = 'pkg_';
break;
}
// Fallback name
if (empty($extName)) {
$extName = $repoName !== '' ? $repoName : basename($root);
}
echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n";
// ── Build release name ──────────────────────────────────────────────────────
// Use display-name from manifest.xml if available, otherwise fall back to extName
$displayName = !empty($prettyName) ? $prettyName : $extName;
$releaseName = "{$displayName} {$version} ({$typePrefix}{$extElement}-{$version})";
echo "Release name: {$releaseName}\n";
// ── Generate release notes ──────────────────────────────────────────────────
$releaseNotes = "Release {$version}";
$releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php';
if (file_exists($releaseNotesScript)) {
$cmd = sprintf(
'php %s --path %s --version %s',
escapeshellarg($releaseNotesScript),
escapeshellarg($root),
escapeshellarg($version)
);
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode === 0 && count($output) > 0) {
$notes = implode("\n", $output);
if (trim($notes) !== '') {
$releaseNotes = $notes;
echo "Release notes: generated from CHANGELOG.md\n";
} }
// Find Dolibarr module file
$modFile = null;
$modFiles = array_merge(
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($modFiles as $file) {
$c = file_get_contents($file);
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
$modFile = $file;
break;
}
}
// Extract metadata based on platform
switch (true) {
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest);
if ($xml === false) {
break;
}
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
$extElement = $pm2[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1];
}
if (empty($extElement)) {
$extElement = strtolower(basename($extManifest, '.xml'));
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
}
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
$extName = trim($nm[1]);
}
break;
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
$extType = 'dolibarr-module';
$modBasename = basename($modFile, '.class.php');
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename);
$modContent = file_get_contents($modFile);
if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) {
$extName = $nm2[1];
}
break;
default:
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
$extType = 'generic';
break;
}
// Strip existing type prefix from element to prevent duplication
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement;
// Compute type prefix
switch ($extType) {
case 'plugin':
$typePrefix = "plg_{$extFolder}_";
break;
case 'module':
$typePrefix = 'mod_';
break;
case 'component':
$typePrefix = 'com_';
break;
case 'template':
$typePrefix = 'tpl_';
break;
case 'library':
$typePrefix = 'lib_';
break;
case 'package':
$typePrefix = 'pkg_';
break;
}
// Fallback name
if (empty($extName)) {
$extName = $repoName !== '' ? $repoName : basename($root);
}
echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n";
// ── Build release name ──────────────────────────────────────────────────────
$displayName = !empty($prettyName) ? $prettyName : $extName;
$releaseName = "{$displayName} (VERSION: {$version})";
echo "Release name: {$releaseName}\n";
// ── Generate release notes ──────────────────────────────────────────────────
$releaseNotes = "Release {$version}";
$releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php';
if (file_exists($releaseNotesScript)) {
$cmd = sprintf(
'php %s --path %s --version %s',
escapeshellarg($releaseNotesScript),
escapeshellarg($root),
escapeshellarg($version)
);
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode === 0 && count($output) > 0) {
$notes = implode("\n", $output);
if (trim($notes) !== '') {
$releaseNotes = $notes;
echo "Release notes: generated from CHANGELOG.md\n";
}
}
}
// ── Delete existing release at tag (if present) ─────────────────────────────
$existing = $this->giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
if ($existing !== null && !empty($existing['id'])) {
$existingId = $existing['id'];
echo "Deleting existing release: {$tag} (id: {$existingId})\n";
// Delete release
$this->giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE');
// Delete tag
$this->giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE');
}
// ── Create new release ──────────────────────────────────────────────────────
$payload = json_encode([
'tag_name' => $tag,
'target_commitish' => $branch,
'name' => $releaseName,
'body' => $releaseNotes,
'prerelease' => $prerelease,
]);
$newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}');
if ($newRelease === null || empty($newRelease['id'])) {
$this->log('ERROR', "Failed to create release at tag: {$tag}");
return 1;
}
$releaseId = $newRelease['id'];
echo "Created release: {$tag} (id: {$releaseId})\n";
// Output release_id to stdout for CI consumption
echo "release_id={$releaseId}\n";
return 0;
}
private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url);
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) {
return null;
}
$decoded = json_decode($response, true);
return is_array($decoded) ? $decoded : null;
} }
} }
// ── Delete existing release at tag (if present) ───────────────────────────── $app = new ReleaseCreateCli();
exit($app->execute());
$existing = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
if ($existing !== null && !empty($existing['id'])) {
$existingId = $existing['id'];
echo "Deleting existing release: {$tag} (id: {$existingId})\n";
// Delete release
giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE');
// Delete tag
giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE');
}
// ── Create new release ──────────────────────────────────────────────────────
$payload = json_encode([
'tag_name' => $tag,
'target_commitish' => $branch,
'name' => $releaseName,
'body' => $releaseNotes,
'prerelease' => $prerelease,
]);
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}');
if ($newRelease === null || empty($newRelease['id'])) {
fwrite(STDERR, "Failed to create release at tag: {$tag}\n");
exit(1);
}
$releaseId = $newRelease['id'];
echo "Created release: {$tag} (id: {$releaseId})\n";
// Output release_id to stdout for CI consumption
echo "release_id={$releaseId}\n";
exit(0);
+154 -218
View File
@@ -1,239 +1,175 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_manage.php * PATH: /cli/release_manage.php
* BRIEF: Create/update Gitea releases, upload assets, update release body * BRIEF: Create/update Gitea releases, upload assets, update release body
*
* Usage:
* # Create a release
* php release_manage.php --action create --tag stable --name "My Plugin 04.01.00" \
* --body "Release notes" --target main --token TOKEN --api-base URL
*
* # Upload assets to a release
* php release_manage.php --action upload --tag stable --files "/tmp/pkg.zip,/tmp/pkg.tar.gz" \
* --token TOKEN --api-base URL
*
* # Update release body (e.g. add SHA checksums)
* php release_manage.php --action update-body --tag stable --body "New body" \
* --token TOKEN --api-base URL
*
* # Delete a release and its tag
* php release_manage.php --action delete --tag stable --token TOKEN --api-base URL
*
* Options:
* --action create | upload | update-body | delete (required)
* --tag Release tag name (required)
* --name Release name/title (for create)
* --body Release body/description (for create, update-body)
* --body-file Read body from file instead of --body
* --target Target branch/commitish (for create, default: main)
* --files Comma-separated file paths to upload (for upload)
* --token Gitea API token (or MOKOGITEA_TOKEN/GITEA_TOKEN env var)
* --api-base Gitea API base URL (e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo)
*
* NOTE: This script uses PHP curl for all HTTP operations (no shell calls).
*/ */
declare(strict_types=1); declare(strict_types=1);
$action = null; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$tag = null;
$name = null;
$body = null;
$bodyFile = null;
$target = 'main';
$files = [];
$token = null;
$apiBase = null;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--action' && isset($argv[$i + 1])) $action = $argv[$i + 1];
if ($arg === '--tag' && isset($argv[$i + 1])) $tag = $argv[$i + 1];
if ($arg === '--name' && isset($argv[$i + 1])) $name = $argv[$i + 1];
if ($arg === '--body' && isset($argv[$i + 1])) $body = $argv[$i + 1];
if ($arg === '--body-file' && isset($argv[$i + 1])) $bodyFile = $argv[$i + 1];
if ($arg === '--target' && isset($argv[$i + 1])) $target = $argv[$i + 1];
if ($arg === '--files' && isset($argv[$i + 1])) $files = array_filter(explode(',', $argv[$i + 1]));
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
}
// Allow token from environment class ReleaseManageCli extends CliFramework
if ($token === null) {
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
}
// Read body from file if specified
if ($bodyFile !== null && file_exists($bodyFile)) {
$body = file_get_contents($bodyFile);
}
if ($action === null || $tag === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL\n");
exit(1);
}
/**
* Make a Gitea API request using curl
*/
function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
{ {
$ch = curl_init($url); protected function configure(): void
$headers = ["Authorization: token {$token}"]; {
$this->setDescription('Create/update Gitea releases, upload assets, update release body');
$this->addArgument('--action', 'create | upload | update-body | delete', null);
$this->addArgument('--tag', 'Release tag name', null);
$this->addArgument('--name', 'Release name/title', null);
$this->addArgument('--body', 'Release body/description', null);
$this->addArgument('--body-file', 'Read body from file', null);
$this->addArgument('--target', 'Target branch/commitish', 'main');
$this->addArgument('--files', 'Comma-separated file paths to upload', null);
$this->addArgument('--token', 'Gitea API token', null);
$this->addArgument('--api-base', 'Gitea API base URL', null);
}
$opts = [ protected function run(): int
CURLOPT_RETURNTRANSFER => true, {
CURLOPT_TIMEOUT => 60, $action = $this->getArgument('--action');
CURLOPT_CUSTOMREQUEST => $method, $tag = $this->getArgument('--tag');
]; $name = $this->getArgument('--name');
$body = $this->getArgument('--body');
$bodyFile = $this->getArgument('--body-file');
$target = $this->getArgument('--target');
$filesArg = $this->getArgument('--files');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : [];
if ($token === null) {
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
}
if ($bodyFile !== null && file_exists($bodyFile)) {
$body = file_get_contents($bodyFile);
}
if ($action === null || $tag === null || $token === null || $apiBase === null) {
$this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL");
return 1;
}
switch ($action) {
case 'create':
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$existingId = $existing['id'];
$this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
}
$payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]);
$result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
$releaseId = $result['data']['id'] ?? 'unknown';
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
} else {
$this->log('ERROR', "Failed to create release: HTTP {$result['code']}");
return 1;
}
break;
case 'upload':
if (empty($files)) {
$this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2");
return 1;
}
$release = $this->getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
$this->log('ERROR', "No release found for tag: {$tag}");
return 1;
}
$releaseId = $release['id'];
$assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
$existingAssets = $assetsResult['data'] ?? [];
foreach ($files as $filePath) {
$filePath = trim($filePath);
if (!file_exists($filePath)) {
$this->log('ERROR', "File not found: {$filePath}");
continue;
}
$fileName = basename($filePath);
foreach ($existingAssets as $asset) {
if (($asset['name'] ?? '') === $fileName) {
$this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
echo "Deleted existing asset: {$fileName}\n";
break;
}
}
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
$result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Uploaded: {$fileName}\n";
} else {
$this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}");
}
}
break;
case 'update-body':
$release = $this->getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
$this->log('ERROR', "No release found for tag: {$tag}");
return 1;
}
$payload = json_encode(['body' => $body ?? '']);
$result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Release body updated for tag: {$tag}\n";
} else {
$this->log('ERROR', "Failed to update body: HTTP {$result['code']}");
return 1;
}
break;
case 'delete':
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted: {$tag} (id: {$existing['id']})\n";
} else {
echo "No release found for tag: {$tag}\n";
}
break;
default:
$this->log('ERROR', "Unknown action: {$action}");
return 1;
}
return 0;
}
if ($jsonBody !== null) { private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
$headers[] = 'Content-Type: application/json'; {
$opts[CURLOPT_POSTFIELDS] = $jsonBody; $ch = curl_init($url);
} elseif ($filePath !== null) { $headers = ["Authorization: token {$token}"];
$headers[] = 'Content-Type: application/octet-stream'; $opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); if ($jsonBody !== null) {
} $headers[] = 'Content-Type: application/json';
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
} elseif ($filePath !== null) {
$headers[] = 'Content-Type: application/octet-stream';
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath);
}
$opts[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []];
}
$opts[CURLOPT_HTTPHEADER] = $headers; private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
curl_setopt_array($ch, $opts); {
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
$response = curl_exec($ch); return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); }
curl_close($ch);
$data = json_decode($response ?: '{}', true) ?: [];
return ['code' => $httpCode, 'data' => $data];
} }
/** $app = new ReleaseManageCli();
* Get release by tag exit($app->execute());
*/
function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
{
$result = releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
if ($result['code'] === 200 && isset($result['data']['id'])) {
return $result['data'];
}
return null;
}
// -- Action dispatch ----------------------------------------------------------
switch ($action) {
case 'create':
// Delete existing release if present
$existing = getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$existingId = $existing['id'];
releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
}
$payload = json_encode([
'tag_name' => $tag,
'name' => $name ?? $tag,
'body' => $body ?? '',
'target_commitish' => $target,
]);
$result = releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
$releaseId = $result['data']['id'] ?? 'unknown';
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
} else {
fwrite(STDERR, "Failed to create release: HTTP {$result['code']}\n");
fwrite(STDERR, json_encode($result['data']) . "\n");
exit(1);
}
break;
case 'upload':
if (empty($files)) {
fwrite(STDERR, "No files specified. Use --files /path/to/file1,/path/to/file2\n");
exit(1);
}
$release = getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
fwrite(STDERR, "No release found for tag: {$tag}\n");
exit(1);
}
$releaseId = $release['id'];
// Get existing assets to avoid duplicates
$assetsResult = releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
$existingAssets = $assetsResult['data'] ?? [];
foreach ($files as $filePath) {
$filePath = trim($filePath);
if (!file_exists($filePath)) {
fwrite(STDERR, "File not found: {$filePath}\n");
continue;
}
$fileName = basename($filePath);
// Delete existing asset with same name
foreach ($existingAssets as $asset) {
if (($asset['name'] ?? '') === $fileName) {
releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
echo "Deleted existing asset: {$fileName}\n";
break;
}
}
// Upload
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
$result = releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Uploaded: {$fileName}\n";
} else {
fwrite(STDERR, "Failed to upload {$fileName}: HTTP {$result['code']}\n");
}
}
break;
case 'update-body':
$release = getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
fwrite(STDERR, "No release found for tag: {$tag}\n");
exit(1);
}
$releaseId = $release['id'];
$payload = json_encode(['body' => $body ?? '']);
$result = releaseGiteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Release body updated for tag: {$tag}\n";
} else {
fwrite(STDERR, "Failed to update body: HTTP {$result['code']}\n");
exit(1);
}
break;
case 'delete':
$existing = getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted: {$tag} (id: {$existing['id']})\n";
} else {
echo "No release found for tag: {$tag}\n";
}
break;
default:
fwrite(STDERR, "Unknown action: {$action}\n");
fwrite(STDERR, "Valid actions: create, upload, update-body, delete\n");
exit(1);
}
exit(0);
+223 -276
View File
@@ -6,295 +6,242 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_mirror.php * PATH: /cli/release_mirror.php
* BRIEF: Mirror a Gitea release (with assets) to a GitHub repository * BRIEF: Mirror a Gitea release (with assets) to a GitHub repository
*
* Usage:
* php release_mirror.php --version 09.01.00 --tag stable --token TOKEN --api-base URL \
* --gh-token GH_MIRROR_TOKEN --gh-repo MokoConsulting/MokoWaaS
*
* Mirrors a Gitea release (title, body, assets) to a corresponding GitHub release.
* If the GitHub release already exists at the same tag, its title is updated via PATCH.
* All assets from the Gitea release are downloaded and uploaded to the GitHub release.
*/ */
declare(strict_types=1); declare(strict_types=1);
// ── Argument parsing ───────────────────────────────────────────────────────── require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null; use MokoCli\CliFramework;
$tag = null;
$token = null;
$apiBase = null;
$ghToken = null;
$ghRepo = null;
$branch = 'main';
foreach ($argv as $i => $arg) { class ReleaseMirrorCli extends CliFramework
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--tag' && isset($argv[$i + 1])) {
$tag = $argv[$i + 1];
}
if ($arg === '--token' && isset($argv[$i + 1])) {
$token = $argv[$i + 1];
}
if ($arg === '--api-base' && isset($argv[$i + 1])) {
$apiBase = $argv[$i + 1];
}
if ($arg === '--gh-token' && isset($argv[$i + 1])) {
$ghToken = $argv[$i + 1];
}
if ($arg === '--gh-repo' && isset($argv[$i + 1])) {
$ghRepo = $argv[$i + 1];
}
if ($arg === '--branch' && isset($argv[$i + 1])) {
$branch = $argv[$i + 1];
}
}
// Allow tokens from environment
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
$ghToken = $ghToken ?: (getenv('GH_MIRROR_TOKEN') ?: null);
if (
$version === null || $tag === null || $token === null || $apiBase === null
|| $ghToken === null || $ghRepo === null
) {
fwrite(STDERR, "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " .
"--api-base URL --gh-token GH_MIRROR_TOKEN --gh-repo org/repo [--branch main]\n");
fwrite(STDERR, " --token: Gitea token (or MOKOGITEA_TOKEN / GITEA_TOKEN env)\n");
fwrite(STDERR, " --gh-token: GitHub token (or GH_MIRROR_TOKEN env)\n");
exit(1);
}
// ── Helper: Gitea API request ────────────────────────────────────────────────
/**
* Send a request to the Gitea API.
*
* @param string $url Full Gitea API URL
* @param string $token Gitea API token
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
* @param string|null $body JSON request body or null
*
* @return array<string, mixed>|null Decoded response or null on failure
*/
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{ {
$ch = curl_init($url); protected function configure(): void
curl_setopt_array($ch, [ {
CURLOPT_RETURNTRANSFER => true, $this->setDescription('Mirror a Gitea release (with assets) to a GitHub repository');
CURLOPT_HTTPHEADER => [ $this->addArgument('--version', 'Version string (required)', '');
"Authorization: token {$token}", $this->addArgument('--tag', 'Release tag name (required)', '');
'Content-Type: application/json', $this->addArgument('--token', 'Gitea API token', '');
], $this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
CURLOPT_TIMEOUT => 30, $this->addArgument('--gh-token', 'GitHub personal access token', '');
CURLOPT_CUSTOMREQUEST => $method, $this->addArgument('--gh-repo', 'GitHub org/repo (required)', '');
]); $this->addArgument('--branch', 'Target branch (default: main)', 'main');
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
return null;
}
return json_decode($response, true) ?: null;
}
/**
* Download a file from Gitea to a local path.
*
* @param string $url Download URL
* @param string $token Gitea API token
* @param string $dest Local destination path
*
* @return bool True on success
*/
function giteaDownload(string $url, string $token, string $dest): bool
{
$ch = curl_init($url);
$fp = fopen($dest, 'wb');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
return $httpCode >= 200 && $httpCode < 300;
}
/**
* Send a request to the GitHub API.
*
* @param string $url Full GitHub API URL
* @param string $token GitHub personal access token
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
* @param string|null $body JSON request body or null
*
* @return array<string, mixed>|null Decoded response or null on failure
*/
function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Accept: application/vnd.github+json',
'User-Agent: moko-platform',
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
return null;
}
return json_decode($response, true) ?: null;
}
/**
* Upload a binary asset to a GitHub release.
*
* @param string $uploadUrl GitHub upload URL (uploads.github.com)
* @param string $token GitHub personal access token
* @param string $filePath Local file path to upload
* @param string $name Asset filename for GitHub
*
* @return int HTTP status code
*/
function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int
{
$url = $uploadUrl . '?name=' . urlencode($name);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Accept: application/vnd.github+json',
'User-Agent: moko-platform',
'Content-Type: application/octet-stream',
],
CURLOPT_POSTFIELDS => file_get_contents($filePath),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode;
}
// ── Step 1: Get Gitea release by tag ─────────────────────────────────────────
echo "Fetching Gitea release: {$tag}\n";
$giteaRelease = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
if (!$giteaRelease || empty($giteaRelease['id'])) {
fwrite(STDERR, "No Gitea release found with tag: {$tag}\n");
exit(1);
}
$giteaId = $giteaRelease['id'];
$releaseName = $giteaRelease['name'] ?? "{$version}";
$releaseBody = $giteaRelease['body'] ?? '';
$assets = $giteaRelease['assets'] ?? [];
echo " Name: {$releaseName}\n";
echo " Assets: " . count($assets) . " file(s)\n";
// ── Step 2: Check / create GitHub release ────────────────────────────────────
$ghApiBase = "https://api.github.com/repos/{$ghRepo}";
$ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}";
echo "Checking GitHub release: {$tag}\n";
$ghRelease = githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken);
if ($ghRelease && !empty($ghRelease['id'])) {
// Update existing release title
$ghReleaseId = $ghRelease['id'];
echo " GitHub release exists (id: {$ghReleaseId}), updating title\n";
$patchPayload = json_encode([
'name' => $releaseName,
'body' => $releaseBody,
]);
githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload);
} else {
// Create new release
echo " Creating GitHub release\n";
$createPayload = json_encode([
'tag_name' => $tag,
'target_commitish' => $branch,
'name' => $releaseName,
'body' => $releaseBody,
'draft' => false,
'prerelease' => ($tag !== 'stable'),
]);
$ghRelease = githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload);
if (!$ghRelease || empty($ghRelease['id'])) {
fwrite(STDERR, "Failed to create GitHub release\n");
exit(1);
}
$ghReleaseId = $ghRelease['id'];
echo " Created GitHub release (id: {$ghReleaseId})\n";
}
// ── Step 3: Download assets from Gitea ───────────────────────────────────────
$tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid();
@mkdir($tmpDir, 0755, true);
$uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets";
foreach ($assets as $asset) {
$name = $asset['name'] ?? '';
$downloadUrl = $asset['browser_download_url'] ?? '';
if ($name === '' || $downloadUrl === '') {
continue;
} }
$localPath = "{$tmpDir}/{$name}"; protected function run(): int
echo " Downloading: {$name}\n"; {
$version = $this->getArgument('--version');
$tag = $this->getArgument('--tag');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$ghToken = $this->getArgument('--gh-token');
$ghRepo = $this->getArgument('--gh-repo');
$branch = $this->getArgument('--branch');
if (!giteaDownload($downloadUrl, $token, $localPath)) { // Allow tokens from environment
fwrite(STDERR, " Failed to download: {$name}\n"); $token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: ''));
continue; $ghToken = $ghToken ?: (getenv('GH_MIRROR_TOKEN') ?: '');
if ($version === '' || $tag === '' || $token === '' || $apiBase === '' || $ghToken === '' || $ghRepo === '') {
$this->log('ERROR', "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " .
"--api-base URL --gh-token GH_MIRROR_TOKEN --gh-repo org/repo [--branch main]");
$this->log('ERROR', " --token: Gitea token (or MOKOGITEA_TOKEN / GITEA_TOKEN env)");
$this->log('ERROR', " --gh-token: GitHub token (or GH_MIRROR_TOKEN env)");
return 1;
}
// ── Step 1: Get Gitea release by tag ─────────────────────────────────────────
echo "Fetching Gitea release: {$tag}\n";
$giteaRelease = $this->giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
if (!$giteaRelease || empty($giteaRelease['id'])) {
$this->log('ERROR', "No Gitea release found with tag: {$tag}");
return 1;
}
$giteaId = $giteaRelease['id'];
$releaseName = $giteaRelease['name'] ?? "{$version}";
$releaseBody = $giteaRelease['body'] ?? '';
$assets = $giteaRelease['assets'] ?? [];
echo " Name: {$releaseName}\n";
echo " Assets: " . count($assets) . " file(s)\n";
// ── Step 2: Check / create GitHub release ────────────────────────────────────
$ghApiBase = "https://api.github.com/repos/{$ghRepo}";
$ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}";
echo "Checking GitHub release: {$tag}\n";
$ghRelease = $this->githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken);
if ($ghRelease && !empty($ghRelease['id'])) {
// Update existing release title
$ghReleaseId = $ghRelease['id'];
echo " GitHub release exists (id: {$ghReleaseId}), updating title\n";
$patchPayload = json_encode([
'name' => $releaseName,
'body' => $releaseBody,
]);
$this->githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload);
} else {
// Create new release
echo " Creating GitHub release\n";
$createPayload = json_encode([
'tag_name' => $tag,
'target_commitish' => $branch,
'name' => $releaseName,
'body' => $releaseBody,
'draft' => false,
'prerelease' => ($tag !== 'stable'),
]);
$ghRelease = $this->githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload);
if (!$ghRelease || empty($ghRelease['id'])) {
$this->log('ERROR', 'Failed to create GitHub release');
return 1;
}
$ghReleaseId = $ghRelease['id'];
echo " Created GitHub release (id: {$ghReleaseId})\n";
}
// ── Step 3: Download assets from Gitea ───────────────────────────────────────
$tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid();
@mkdir($tmpDir, 0755, true);
$uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets";
foreach ($assets as $asset) {
$name = $asset['name'] ?? '';
$downloadUrl = $asset['browser_download_url'] ?? '';
if ($name === '' || $downloadUrl === '') {
continue;
}
$localPath = "{$tmpDir}/{$name}";
echo " Downloading: {$name}\n";
if (!$this->giteaDownload($downloadUrl, $token, $localPath)) {
$this->log('ERROR', " Failed to download: {$name}");
continue;
}
// ── Step 4: Upload asset to GitHub ───────────────────────────────────────
echo " Uploading: {$name}\n";
$code = $this->githubUploadAsset($uploadUrl, $ghToken, $localPath, $name);
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
echo " {$status}\n";
}
// ── Cleanup ──────────────────────────────────────────────────────────────────
array_map('unlink', glob("{$tmpDir}/*") ?: []);
@rmdir($tmpDir);
// ── Summary ──────────────────────────────────────────────────────────────────
echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n";
echo " Version: {$version}\n";
echo " Assets: " . count($assets) . " file(s)\n";
return 0;
} }
// ── Step 4: Upload asset to GitHub ─────────────────────────────────────── private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
echo " Uploading: {$name}\n"; {
$code = githubUploadAsset($uploadUrl, $ghToken, $localPath, $name); $ch = curl_init($url);
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})"; curl_setopt_array($ch, [
echo " {$status}\n"; CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
return null;
}
return json_decode($response, true) ?: null;
}
private function giteaDownload(string $url, string $token, string $dest): bool
{
$ch = curl_init($url);
$fp = fopen($dest, 'wb');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
return $httpCode >= 200 && $httpCode < 300;
}
private function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Accept: application/vnd.github+json',
'User-Agent: mokocli',
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
return null;
}
return json_decode($response, true) ?: null;
}
private function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int
{
$url = $uploadUrl . '?name=' . urlencode($name);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Accept: application/vnd.github+json',
'User-Agent: mokocli',
'Content-Type: application/octet-stream',
],
CURLOPT_POSTFIELDS => file_get_contents($filePath),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode;
}
} }
// ── Cleanup ────────────────────────────────────────────────────────────────── $app = new ReleaseMirrorCli();
exit($app->execute());
array_map('unlink', glob("{$tmpDir}/*") ?: []);
@rmdir($tmpDir);
// ── Summary ──────────────────────────────────────────────────────────────────
echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n";
echo " Version: {$version}\n";
echo " Assets: " . count($assets) . " file(s)\n";
exit(0);
+65 -48
View File
@@ -1,66 +1,83 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_notes.php * PATH: /cli/release_notes.php
* BRIEF: Extract release notes from CHANGELOG.md for a given version * BRIEF: Extract release notes from CHANGELOG.md for a given version
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
}
if ($version === null) { use MokoCli\CliFramework;
// Read from README.md
$readme = realpath($path) . '/README.md'; class ReleaseNotesCli extends CliFramework
if (file_exists($readme)) { {
$content = file_get_contents($readme); protected function configure(): void
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { {
$version = $m[1]; $this->setDescription('Extract release notes from CHANGELOG.md for a given version');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Version to extract notes for', '');
}
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version') ?: null;
if ($version === null || $version === '') {
// Read from README.md
$readme = realpath($path) . '/README.md';
if (file_exists($readme)) {
$content = file_get_contents($readme);
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$version = $m[1];
}
}
} }
if ($version === null || $version === '') {
$this->log('ERROR', 'Usage: release_notes.php --path . --version XX.YY.ZZ');
return 1;
}
$changelog = realpath($path) . '/CHANGELOG.md';
if (!file_exists($changelog)) {
echo "Release {$version}\n";
return 0;
}
$lines = file($changelog, FILE_IGNORE_NEW_LINES);
$notes = [];
$capturing = false;
foreach ($lines as $line) {
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
$capturing = true;
continue;
}
if ($capturing && preg_match('/^## /', $line)) {
break;
}
if ($capturing) {
$notes[] = $line;
}
}
$result = trim(implode("\n", $notes));
echo $result ?: "Release {$version}";
echo "\n";
return 0;
} }
} }
if ($version === null) { $app = new ReleaseNotesCli();
fwrite(STDERR, "Usage: release_notes.php --path . --version XX.YY.ZZ\n"); exit($app->execute());
exit(1);
}
$changelog = realpath($path) . '/CHANGELOG.md';
if (!file_exists($changelog)) {
echo "Release {$version}\n";
exit(0);
}
$lines = file($changelog, FILE_IGNORE_NEW_LINES);
$notes = [];
$capturing = false;
foreach ($lines as $line) {
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
$capturing = true;
continue;
}
if ($capturing && preg_match('/^## /', $line)) {
break; // Next version heading — stop
}
if ($capturing) {
$notes[] = $line;
}
}
$result = trim(implode("\n", $notes));
echo $result ?: "Release {$version}";
echo "\n";
exit(0);
+517 -535
View File
File diff suppressed because it is too large Load Diff
+280 -286
View File
@@ -6,311 +6,305 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_promote.php * PATH: /cli/release_promote.php
* BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets) * BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets)
*
* Usage:
* php release_promote.php --from development --to release-candidate --token TOKEN --api-base URL
* php release_promote.php --from release-candidate --to stable --token TOKEN --api-base URL --path .
*
* When promoting to stable, --path detects extension type prefix for asset renaming.
* When --from is "auto", checks beta > alpha > development and uses the first found.
*/ */
declare(strict_types=1); declare(strict_types=1);
$from = null; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$to = null;
$token = null;
$apiBase = null;
$path = '.';
$branch = 'main';
foreach ($argv as $i => $arg) { use MokoCli\{CliFramework, SourceResolver};
if ($arg === '--from' && isset($argv[$i + 1])) {
$from = $argv[$i + 1];
}
if ($arg === '--to' && isset($argv[$i + 1])) {
$to = $argv[$i + 1];
}
if ($arg === '--token' && isset($argv[$i + 1])) {
$token = $argv[$i + 1];
}
if ($arg === '--api-base' && isset($argv[$i + 1])) {
$apiBase = $argv[$i + 1];
}
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--branch' && isset($argv[$i + 1])) {
$branch = $argv[$i + 1];
}
}
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null)); class ReleasePromoteCli extends CliFramework
if ($to === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: release_promote.php --from <channel|auto> --to <channel> --token TOKEN --api-base URL [--path .]\n");
fwrite(STDERR, " --from auto: checks beta > alpha > development\n");
exit(1);
}
// ── Suffix maps ──────────────────────────────────────────────────────────────
$suffixMap = [
'development' => '-dev',
'alpha' => '-alpha',
'beta' => '-beta',
'release-candidate' => '-rc',
'stable' => '',
];
// ── Channel hierarchy (highest first) ────────────────────────────────────────
$channelOrder = ['beta', 'alpha', 'development'];
// ── Helper: Gitea API request ────────────────────────────────────────────────
/** @return array<string, mixed>|null */
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{ {
$ch = curl_init($url); protected function configure(): void
curl_setopt_array($ch, [ {
CURLOPT_RETURNTRANSFER => true, $this->setDescription('Promote a Gitea release from one channel to another');
CURLOPT_HTTPHEADER => [ $this->addArgument('--from', 'Source channel (or "auto")', '');
"Authorization: token {$token}", $this->addArgument('--to', 'Target channel (required)', '');
'Content-Type: application/json', $this->addArgument('--token', 'Gitea API token', '');
], $this->addArgument('--api-base', 'Gitea API base URL for the repo', '');
CURLOPT_TIMEOUT => 30, $this->addArgument('--path', 'Repository root for type prefix detection', '.');
CURLOPT_CUSTOMREQUEST => $method, $this->addArgument('--branch', 'Target branch', 'main');
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} }
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) { protected function run(): int
return null; {
} $from = $this->getArgument('--from') ?: null;
return json_decode($response, true) ?: null; $to = $this->getArgument('--to') ?: null;
} $token = $this->getArgument('--token') ?: null;
$apiBase = $this->getArgument('--api-base') ?: null;
$path = $this->getArgument('--path');
$branch = $this->getArgument('--branch');
function giteaDownload(string $url, string $token, string $dest): bool $token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
{
$ch = curl_init($url);
$fp = fopen($dest, 'wb');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
return $httpCode >= 200 && $httpCode < 300;
}
// ── Resolve --from auto ────────────────────────────────────────────────────── if ($to === null || $token === null || $apiBase === null) {
if ($from === 'auto') { $this->log('ERROR', "Usage: release_promote.php --from <channel|auto> --to <channel> --token TOKEN --api-base URL [--path .]");
foreach ($channelOrder as $candidate) { $this->log('ERROR', " --from auto: checks beta > alpha > development");
$data = giteaApi("{$apiBase}/releases/tags/{$candidate}", $token); return 1;
if ($data && !empty($data['id'])) {
$from = $candidate;
echo "Auto-detected source channel: {$from}\n";
break;
}
}
if ($from === 'auto') {
echo "No pre-release found to promote\n";
exit(0);
}
}
// ── Find source release ──────────────────────────────────────────────────────
$sourceRelease = giteaApi("{$apiBase}/releases/tags/{$from}", $token);
if (!$sourceRelease || empty($sourceRelease['id'])) {
fwrite(STDERR, "No release found with tag: {$from}\n");
exit(1);
}
$sourceId = $sourceRelease['id'];
$sourceName = $sourceRelease['name'] ?? '';
$sourceBody = $sourceRelease['body'] ?? '';
echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n";
// ── Get source assets ────────────────────────────────────────────────────────
$assets = giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: [];
echo "Assets: " . count($assets) . " file(s)\n";
// ── Download assets to temp ──────────────────────────────────────────────────
$tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid();
@mkdir($tmpDir, 0755, true);
foreach ($assets as $asset) {
$name = $asset['name'];
$downloadUrl = $asset['browser_download_url'];
echo " Downloading: {$name}\n";
giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}");
}
// ── Detect type prefix for stable promotion ──────────────────────────────────
$typePrefix = '';
if ($to === 'stable') {
$root = realpath($path) ?: $path;
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $xmlFile) {
$xmlContent = file_get_contents($xmlFile);
if (strpos($xmlContent, '<extension') === false) {
continue;
} }
$extType = ''; // ── Suffix maps ──────────────────────────────────────────────────────────────
$extFolder = ''; $suffixMap = [
if (preg_match('/type="([^"]*)"/', $xmlContent, $tm)) { 'development' => '-dev',
$extType = $tm[1]; 'alpha' => '-alpha',
} 'beta' => '-beta',
if (preg_match('/group="([^"]*)"/', $xmlContent, $gm)) { 'release-candidate' => '-rc',
$extFolder = $gm[1]; 'stable' => '',
];
// ── Channel hierarchy (highest first) ────────────────────────────────────────
$channelOrder = ['beta', 'alpha', 'development'];
// ── Resolve --from auto ──────────────────────────────────────────────────────
if ($from === 'auto') {
foreach ($channelOrder as $candidate) {
$data = $this->giteaApi("{$apiBase}/releases/tags/{$candidate}", $token);
if ($data && !empty($data['id'])) {
$from = $candidate;
echo "Auto-detected source channel: {$from}\n";
break;
}
}
if ($from === 'auto') {
echo "No pre-release found to promote\n";
return 0;
}
} }
switch ($extType) { // ── Find source release ──────────────────────────────────────────────────────
case 'plugin': $sourceRelease = $this->giteaApi("{$apiBase}/releases/tags/{$from}", $token);
$typePrefix = "plg_{$extFolder}_"; if (!$sourceRelease || empty($sourceRelease['id'])) {
break; $this->log('ERROR', "No release found with tag: {$from}");
case 'module': return 1;
$typePrefix = 'mod_';
break;
case 'component':
$typePrefix = 'com_';
break;
case 'template':
$typePrefix = 'tpl_';
break;
case 'library':
$typePrefix = 'lib_';
break;
case 'package':
$typePrefix = 'pkg_';
break;
} }
if ($typePrefix !== '') {
break; $sourceId = $sourceRelease['id'];
$sourceName = $sourceRelease['name'] ?? '';
$sourceBody = $sourceRelease['body'] ?? '';
echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n";
// ── Get source assets ────────────────────────────────────────────────────────
$assets = $this->giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: [];
echo "Assets: " . count($assets) . " file(s)\n";
// ── Download assets to temp ──────────────────────────────────────────────────
$tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid();
@mkdir($tmpDir, 0755, true);
foreach ($assets as $asset) {
$name = $asset['name'];
$downloadUrl = $asset['browser_download_url'];
echo " Downloading: {$name}\n";
$this->giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}");
} }
// ── Detect type prefix for stable promotion ──────────────────────────────────
$typePrefix = '';
if ($to === 'stable') {
$root = realpath($path) ?: $path;
$manifestFiles = array_merge(
SourceResolver::globSource($root, 'pkg_*.xml'),
SourceResolver::globSource($root, '*.xml'),
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $xmlFile) {
$xmlContent = file_get_contents($xmlFile);
if (strpos($xmlContent, '<extension') === false) {
continue;
}
$extType = '';
$extFolder = '';
if (preg_match('/type="([^"]*)"/', $xmlContent, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xmlContent, $gm)) {
$extFolder = $gm[1];
}
switch ($extType) {
case 'plugin':
$typePrefix = "plg_{$extFolder}_";
break;
case 'module':
$typePrefix = 'mod_';
break;
case 'component':
$typePrefix = 'com_';
break;
case 'template':
$typePrefix = 'tpl_';
break;
case 'library':
$typePrefix = 'lib_';
break;
case 'package':
$typePrefix = 'pkg_';
break;
}
if ($typePrefix !== '') {
break;
}
}
}
// ── Rename assets ────────────────────────────────────────────────────────────
$oldSuffix = $suffixMap[$from] ?? '';
$newSuffix = $suffixMap[$to] ?? '';
$renamedAssets = [];
foreach ($assets as $asset) {
$oldName = $asset['name'];
$newName = $oldName;
// Strip old suffix
if ($oldSuffix !== '') {
$newName = str_replace($oldSuffix, '', $newName);
}
// Add type prefix for stable (if not already prefixed)
if ($to === 'stable' && $typePrefix !== '' && strpos($newName, $typePrefix) !== 0) {
// Strip any existing type prefix to prevent duplication
$newName = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $newName);
$newName = $typePrefix . $newName;
}
// Add new suffix (for non-stable targets)
if ($newSuffix !== '' && strpos($newName, $newSuffix) === false) {
// Insert before extension
$newName = preg_replace('/(\.(zip|tar\.gz|sha256))$/', $newSuffix . '$1', $newName);
}
$renamedAssets[] = ['old' => $oldName, 'new' => $newName];
if ($oldName !== $newName) {
echo " Rename: {$oldName}{$newName}\n";
}
}
// ── Delete source release + tag ──────────────────────────────────────────────
$this->giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE');
$this->giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE');
echo "Deleted source: {$from} release + tag\n";
// ── Delete existing target release + tag (if any) ────────────────────────────
$existingTarget = $this->giteaApi("{$apiBase}/releases/tags/{$to}", $token);
if ($existingTarget && !empty($existingTarget['id'])) {
$this->giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE');
$this->giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE');
echo "Deleted existing target: {$to} release + tag\n";
}
// ── Create target release ────────────────────────────────────────────────────
$isPrerelease = ($to !== 'stable');
$newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName);
if ($newName === $sourceName) {
$newName = str_ireplace($from, $to, $sourceName);
}
$newBody = str_ireplace($from, $to, $sourceBody);
$payload = json_encode([
'tag_name' => $to,
'target_commitish' => $branch,
'name' => $newName,
'body' => $newBody,
'prerelease' => $isPrerelease,
]);
$newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $payload);
if (!$newRelease || empty($newRelease['id'])) {
$this->log('ERROR', "Failed to create {$to} release");
return 1;
}
$newId = $newRelease['id'];
echo "Created: {$to} release (id: {$newId})\n";
// ── Upload renamed assets ────────────────────────────────────────────────────
foreach ($renamedAssets as $entry) {
$localFile = "{$tmpDir}/{$entry['old']}";
if (!file_exists($localFile)) {
continue;
}
$uploadName = urlencode($entry['new']);
$url = "{$apiBase}/releases/{$newId}/assets?name={$uploadName}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/octet-stream',
],
CURLOPT_POSTFIELDS => file_get_contents($localFile),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
echo " Upload: {$entry['new']}{$status}\n";
}
// ── Cleanup temp ─────────────────────────────────────────────────────────────
array_map('unlink', glob("{$tmpDir}/*") ?: []);
@rmdir($tmpDir);
echo "Promoted: {$from}{$to}\n";
return 0;
}
private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
return null;
}
return json_decode($response, true) ?: null;
}
private function giteaDownload(string $url, string $token, string $dest): bool
{
$ch = curl_init($url);
$fp = fopen($dest, 'wb');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
return $httpCode >= 200 && $httpCode < 300;
} }
} }
// ── Rename assets ──────────────────────────────────────────────────────────── $app = new ReleasePromoteCli();
$oldSuffix = $suffixMap[$from] ?? ''; exit($app->execute());
$newSuffix = $suffixMap[$to] ?? '';
$renamedAssets = [];
foreach ($assets as $asset) {
$oldName = $asset['name'];
$newName = $oldName;
// Strip old suffix
if ($oldSuffix !== '') {
$newName = str_replace($oldSuffix, '', $newName);
}
// Add type prefix for stable (if not already prefixed)
if ($to === 'stable' && $typePrefix !== '' && strpos($newName, $typePrefix) !== 0) {
// Strip any existing type prefix to prevent duplication
$newName = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $newName);
$newName = $typePrefix . $newName;
}
// Add new suffix (for non-stable targets)
if ($newSuffix !== '' && strpos($newName, $newSuffix) === false) {
// Insert before extension
$newName = preg_replace('/(\.(zip|tar\.gz|sha256))$/', $newSuffix . '$1', $newName);
}
$renamedAssets[] = ['old' => $oldName, 'new' => $newName];
if ($oldName !== $newName) {
echo " Rename: {$oldName}{$newName}\n";
}
}
// ── Delete source release + tag ──────────────────────────────────────────────
giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE');
giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE');
echo "Deleted source: {$from} release + tag\n";
// ── Delete existing target release + tag (if any) ────────────────────────────
$existingTarget = giteaApi("{$apiBase}/releases/tags/{$to}", $token);
if ($existingTarget && !empty($existingTarget['id'])) {
giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE');
giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE');
echo "Deleted existing target: {$to} release + tag\n";
}
// ── Create target release ────────────────────────────────────────────────────
$isPrerelease = ($to !== 'stable');
$newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName);
if ($newName === $sourceName) {
$newName = str_ireplace($from, $to, $sourceName);
}
$newBody = str_ireplace($from, $to, $sourceBody);
$payload = json_encode([
'tag_name' => $to,
'target_commitish' => $branch,
'name' => $newName,
'body' => $newBody,
'prerelease' => $isPrerelease,
]);
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload);
if (!$newRelease || empty($newRelease['id'])) {
fwrite(STDERR, "Failed to create {$to} release\n");
exit(1);
}
$newId = $newRelease['id'];
echo "Created: {$to} release (id: {$newId})\n";
// ── Upload renamed assets ────────────────────────────────────────────────────
foreach ($renamedAssets as $entry) {
$localFile = "{$tmpDir}/{$entry['old']}";
if (!file_exists($localFile)) {
continue;
}
$uploadName = urlencode($entry['new']);
$url = "{$apiBase}/releases/{$newId}/assets?name={$uploadName}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/octet-stream',
],
CURLOPT_POSTFIELDS => file_get_contents($localFile),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
echo " Upload: {$entry['new']}{$status}\n";
}
// ── Cleanup temp ─────────────────────────────────────────────────────────────
array_map('unlink', glob("{$tmpDir}/*") ?: []);
@rmdir($tmpDir);
echo "Promoted: {$from}{$to}\n";
exit(0);
+350 -321
View File
@@ -1,349 +1,378 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_publish.php * PATH: /cli/release_publish.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Publish a release and create copies for all lesser stability streams. * BRIEF: Publish a release and create copies for all lesser stability streams.
*
* When a release is published at a given stability, copies are created for all
* lower stability streams with the same base version and their respective suffix.
* updates.xml is updated for ALL streams and synced to ALL branches.
*
* Usage:
* php release_publish.php --path . --stability stable --token TOKEN
* php release_publish.php --path . --stability rc --token TOKEN --bump minor
* php release_publish.php --path . --stability dev --token TOKEN --bump patch
* php release_publish.php --path . --stability stable --token TOKEN --dry-run
*
* Options:
* --path Repository root (default: .)
* --stability Target stability: dev|alpha|beta|rc|stable (required)
* --token Gitea API token (required)
* --bump Version bump type before release: patch|minor|none (default: none)
* --branch Current branch (default: auto-detect)
* --gitea-url Gitea URL (default: env GITEA_URL)
* --org Organization (default: env GITEA_ORG)
* --repo Repository name (default: env GITEA_REPO)
* --dry-run Preview without making changes
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$stability = '';
$token = '';
$bumpType = 'none';
$branch = '';
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
$org = getenv('GITEA_ORG') ?: '';
$repo = getenv('GITEA_REPO') ?: '';
$dryRun = false;
$repoUrl = '';
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
if ($arg === '--bump' && isset($argv[$i + 1])) $bumpType = $argv[$i + 1];
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1];
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
if ($arg === '--repo-url' && isset($argv[$i + 1])) $repoUrl = $argv[$i + 1];
if ($arg === '--dry-run') $dryRun = true;
}
if (empty($stability) || empty($token)) { class ReleasePublishCli extends CliFramework
fwrite(STDERR, "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]\n"); {
exit(1); protected function configure(): void
} {
$this->setDescription('Publish a release and update stability streams');
$cli = __DIR__; $this->addArgument('--path', 'Repository root (default: .)', '.');
$php = '"' . PHP_BINARY . '"'; $this->addArgument('--stability', 'Target stability: dev|alpha|beta|rc|stable (required)', '');
$giteaUrl = rtrim($giteaUrl, '/'); $this->addArgument('--token', 'Gitea API token (required)', '');
$this->addArgument('--bump', 'Version bump type: patch|minor|none (default: none)', 'none');
// Resolve path early for shell commands (Windows needs native paths) $this->addArgument('--branch', 'Current branch (default: auto-detect)', '');
$resolvedPath = realpath($path) ?: $path; $this->addArgument('--gitea-url', 'Gitea URL', '');
$this->addArgument('--org', 'Organization', '');
// Auto-detect org/repo from git remote if not set $this->addArgument('--repo', 'Repository name', '');
if (empty($org) || empty($repo)) { $this->addArgument('--repo-url', 'Repository URL for git auth', '');
$remote = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git remote get-url origin 2>/dev/null")); $this->addArgument('--skip-update-stream', 'Skip updates.xml generation and sync (managed externally)', false);
if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
if (empty($org)) $org = $m[1];
if (empty($repo)) $repo = $m[2];
}
}
// Auto-construct repo URL for git auth if not provided
if (empty($repoUrl) && !empty($token) && !empty($org) && !empty($repo)) {
$host = preg_replace('#^https?://#', '', $giteaUrl);
$repoUrl = "https://x-access-token:{$token}@{$host}/{$org}/{$repo}.git";
}
// Auto-detect branch
if (empty($branch)) {
$branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git rev-parse --abbrev-ref HEAD 2>/dev/null"));
}
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
// Stability ordering and suffix mapping
$allStabilities = ['dev', 'alpha', 'beta', 'rc', 'stable'];
$suffixMap = [
'dev' => '-dev',
'alpha' => '-alpha',
'beta' => '-beta',
'rc' => '-rc',
'stable' => '',
];
$releaseTagMap = [
'dev' => 'development',
'alpha' => 'alpha',
'beta' => 'beta',
'rc' => 'release-candidate',
'stable' => 'stable',
];
$stabilityIndex = array_search($stability, $allStabilities);
if ($stabilityIndex === false) {
fwrite(STDERR, "Invalid stability: {$stability}\n");
exit(1);
}
echo "=== Release Publish ===\n";
echo "Stability: {$stability} | Bump: {$bumpType} | Branch: {$branch}\n";
echo "Repo: {$org}/{$repo}\n";
// -- Step 1: Version bump (if requested) --
if ($bumpType !== 'none') {
$bumpFlag = $bumpType === 'minor' ? '--minor' : '';
echo "\n--- Step 1: Version bump ({$bumpType}) ---\n";
if (!$dryRun) {
passthru("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " {$bumpFlag} 2>&1");
} else {
echo "[DRY-RUN] Would run version_bump.php {$bumpFlag}\n";
}
}
// -- Step 2: Read version and set stability suffix --
echo "\n--- Step 2: Set version suffix ---\n";
$versionOutput = [];
$devNull = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($resolvedPath) . " {$devNull}", $versionOutput);
$version = trim($versionOutput[0] ?? '');
if (empty($version)) {
fwrite(STDERR, "No version found\n");
exit(1);
}
// Strip existing suffix to get base version
$baseVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
if (!$dryRun) {
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>&1");
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null");
}
$releaseVersion = $baseVersion . $suffixMap[$stability];
echo "Release version: {$releaseVersion}\n";
// -- Step 2b: Update badges and changelog --
if (!$dryRun) {
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
$changelogFile = realpath($path) . '/CHANGELOG.md';
if (file_exists($changelogFile)) {
passthru("{$php} {$cli}/changelog_promote.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
passthru("{$php} {$cli}/changelog_prune.php --path " . escapeshellarg($path) . " --keep 5 2>/dev/null");
}
}
// -- Step 2c: Commit version changes before building --
$root = realpath($path) ?: $path;
if (!$dryRun) {
// Configure git
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\"");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\"");
if (!empty($repoUrl)) {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl));
} }
// Ensure we're on the actual branch (not detached HEAD from PR merge) protected function run(): int
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git fetch origin " . escapeshellarg($branch) . " 2>/dev/null"); {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git checkout -B " . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"); $path = $this->getArgument('--path');
$stability = $this->getArgument('--stability');
$token = $this->getArgument('--token');
$bumpType = $this->getArgument('--bump');
$branch = $this->getArgument('--branch');
$giteaUrl = $this->getArgument('--gitea-url') ?: (getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech');
$org = $this->getArgument('--org') ?: (getenv('GITEA_ORG') ?: '');
$repo = $this->getArgument('--repo') ?: (getenv('GITEA_REPO') ?: '');
$repoUrl = $this->getArgument('--repo-url');
$skipUpdateStream = $this->getArgument('--skip-update-stream');
// Re-apply version changes on the checked-out branch if (empty($stability) || empty($token)) {
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) $this->log('ERROR', "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]");
. " --version " . escapeshellarg($baseVersion) return 1;
. " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null");
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty"));
if ($diffCheck === 'dirty') {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore(release): build {$releaseVersion} [skip ci]")
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
$pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
echo " Committed release changes\n";
echo " Push: " . trim($pushResult ?? '') . "\n";
}
}
// -- Step 3: Build release package --
echo "\n--- Step 3: Build and upload release ---\n";
$releaseTag = $releaseTagMap[$stability];
$sha256 = '';
if (!$dryRun) {
// Create release
passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($releaseVersion)
. " --tag " . escapeshellarg($releaseTag)
. " --token " . escapeshellarg($token)
. " --api-base " . escapeshellarg($apiBase)
. " --repo " . escapeshellarg($repo)
. " --branch " . escapeshellarg($branch) . " 2>&1");
// Build and upload package
$packageOutput = [];
exec("{$php} {$cli}/release_package.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($releaseVersion)
. " --tag " . escapeshellarg($releaseTag)
. " --token " . escapeshellarg($token)
. " --api-base " . escapeshellarg($apiBase)
. " --repo " . escapeshellarg($repo)
. " --output /tmp 2>&1", $packageOutput);
foreach ($packageOutput as $line) {
echo $line . "\n";
// Extract SHA from output
if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $line, $m)) {
$sha256 = $m[1];
} }
}
// Also check GITHUB_OUTPUT $cli = __DIR__;
$ghOutput = getenv('GITHUB_OUTPUT'); $php = '"' . PHP_BINARY . '"';
if ($ghOutput && file_exists($ghOutput)) { $giteaUrl = rtrim($giteaUrl, '/');
$ghContent = file_get_contents($ghOutput);
if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $ghContent, $m)) { // Resolve path early for shell commands (Windows needs native paths)
$sha256 = $m[1]; $resolvedPath = realpath($path) ?: $path;
// Auto-detect org/repo from git remote if not set
if (empty($org) || empty($repo)) {
$cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$remote = trim((string) @shell_exec(
$cd . escapeshellarg($resolvedPath)
. " && git remote get-url origin 2>/dev/null"
));
if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
if (empty($org)) {
$org = $m[1];
}
if (empty($repo)) {
$repo = $m[2];
}
}
} }
}
} else {
echo "[DRY-RUN] Would build and upload {$releaseVersion} to {$releaseTag}\n";
}
// -- Step 4: Build separate packages for all lesser stability streams -- // Auto-construct repo URL for git auth if not provided
// Each stream gets its own ZIP with the correct version INSIDE templateDetails.xml. if (empty($repoUrl) && !empty($token) && !empty($org) && !empty($repo)) {
// Joomla reads version from the ZIP after install, so it must match. $host = preg_replace('#^https?://#', '', $giteaUrl);
echo "\n--- Step 4: Build packages for lesser streams ---\n"; $repoUrl = "https://x-access-token:{$token}@{$host}/{$org}/{$repo}.git";
for ($i = 0; $i < $stabilityIndex; $i++) { }
$lesserStability = $allStabilities[$i];
$lesserTag = $releaseTagMap[$lesserStability];
$lesserVersion = $baseVersion . $suffixMap[$lesserStability];
echo " Building {$lesserStability} release: {$lesserVersion}\n"; // Auto-detect branch
if (empty($branch)) {
$cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$branch = getenv('GITHUB_REF_NAME')
?: trim((string) @shell_exec(
$cdCmd . escapeshellarg($resolvedPath)
. " && git rev-parse --abbrev-ref HEAD 2>/dev/null"
));
}
if (!$dryRun) { $apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
// Set version to lesser stream's suffixed version in source files
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($lesserStability)
. " --stability " . escapeshellarg($lesserStability) . " 2>/dev/null");
// Create release tag // Stability ordering and suffix mapping
passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path) $allStabilities = ['dev', 'alpha', 'beta', 'rc', 'stable'];
. " --version " . escapeshellarg($lesserVersion) $suffixMap = [
. " --tag " . escapeshellarg($lesserTag) 'dev' => '-dev',
. " --token " . escapeshellarg($token) 'alpha' => '-alpha',
. " --api-base " . escapeshellarg($apiBase) 'beta' => '-beta',
. " --repo " . escapeshellarg($repo) 'rc' => '-rc',
. " --branch " . escapeshellarg($branch) . " 2>&1"); 'stable' => '',
];
$releaseTagMap = [
'dev' => 'development',
'alpha' => 'alpha',
'beta' => 'beta',
'rc' => 'release-candidate',
'stable' => 'stable',
];
// Build and upload package (ZIP will contain the lesser version) $stabilityIndex = array_search($stability, $allStabilities);
passthru("{$php} {$cli}/release_package.php --path " . escapeshellarg($path) if ($stabilityIndex === false) {
. " --version " . escapeshellarg($lesserVersion) $this->log('ERROR', "Invalid stability: {$stability}");
. " --tag " . escapeshellarg($lesserTag) return 1;
. " --token " . escapeshellarg($token) }
. " --api-base " . escapeshellarg($apiBase)
. " --repo " . escapeshellarg($repo) echo "=== Release Publish ===\n";
. " --output /tmp 2>&1"); echo "Stability: {$stability} | Bump: {$bumpType} | Branch: {$branch}\n";
} else { echo "Repo: {$org}/{$repo}\n";
echo " [DRY-RUN] Would build {$lesserVersion} ZIP and upload to {$lesserTag}\n";
// -- Step 1: Version bump (if requested) --
if ($bumpType !== 'none') {
$bumpFlag = $bumpType === 'minor' ? '--minor' : '';
echo "\n--- Step 1: Version bump ({$bumpType}) ---\n";
if (!$this->dryRun) {
passthru("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " {$bumpFlag} 2>&1");
} else {
echo "[DRY-RUN] Would run version_bump.php {$bumpFlag}\n";
}
}
// -- Step 2: Read version and set stability suffix --
echo "\n--- Step 2: Set version suffix ---\n";
$versionOutput = [];
$devNull = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($resolvedPath) . " {$devNull}", $versionOutput);
$version = trim($versionOutput[0] ?? '');
if (empty($version)) {
$this->log('ERROR', 'No version found');
return 1;
}
// Strip existing suffix to get base version
$baseVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
if (!$this->dryRun) {
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>&1");
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null");
}
$releaseVersion = $baseVersion . $suffixMap[$stability];
echo "Release version: {$releaseVersion}\n";
// -- Step 2b: Update badges and changelog --
if (!$this->dryRun) {
passthru(
"{$php} {$cli}/badge_update.php --path "
. escapeshellarg($path) . " --version "
. escapeshellarg($baseVersion) . " 2>/dev/null"
);
$changelogFile = realpath($path) . '/CHANGELOG.md';
if (file_exists($changelogFile)) {
passthru(
"{$php} {$cli}/changelog_promote.php --path "
. escapeshellarg($path) . " --version "
. escapeshellarg($baseVersion) . " 2>/dev/null"
);
passthru(
"{$php} {$cli}/changelog_prune.php --path "
. escapeshellarg($path) . " --keep 5 2>/dev/null"
);
}
}
// -- Step 2c: Commit version changes before building --
$root = realpath($path) ?: $path;
if (!$this->dryRun) {
// Configure git
$cdPfx = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$cdR = $cdPfx . escapeshellarg($root);
@shell_exec(
$cdR . " && git config --local user.email"
. " \"gitea-actions[bot]@mokoconsulting.tech\""
);
@shell_exec(
$cdR . " && git config --local user.name"
. " \"gitea-actions[bot]\""
);
if (!empty($repoUrl)) {
@shell_exec(
$cdR . " && git remote set-url origin "
. escapeshellarg($repoUrl)
);
}
// Ensure we're on the actual branch (not detached HEAD from PR merge)
@shell_exec(
$cdR . " && git fetch origin "
. escapeshellarg($branch) . " 2>/dev/null"
);
@shell_exec(
$cdR . " && git checkout -B "
. escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"
);
// Re-apply version changes on the checked-out branch
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
passthru(
"{$php} {$cli}/version_check.php --path "
. escapeshellarg($path) . " --fix 2>/dev/null"
);
passthru(
"{$php} {$cli}/badge_update.php --path "
. escapeshellarg($path) . " --version "
. escapeshellarg($baseVersion) . " 2>/dev/null"
);
$diffCheck = trim((string) @shell_exec(
$cdR . " && git diff --quiet"
. " && git diff --cached --quiet"
. " 2>&1 && echo clean || echo dirty"
));
if ($diffCheck === 'dirty') {
@shell_exec($cdR . " && git add -A");
$commitMsg = "chore(release): build"
. " {$releaseVersion} [skip ci]";
@shell_exec(
$cdR . " && git commit -m "
. escapeshellarg($commitMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
$pushResult = @shell_exec(
$cdR . " && git push origin "
. escapeshellarg($branch) . " 2>&1"
);
echo " Committed release changes\n";
echo " Push: " . trim($pushResult ?? '') . "\n";
}
}
// -- Step 3: Build release package --
echo "\n--- Step 3: Build and upload release ---\n";
$releaseTag = $releaseTagMap[$stability];
$sha256 = '';
if (!$this->dryRun) {
// Create release
passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($releaseVersion)
. " --tag " . escapeshellarg($releaseTag)
. " --token " . escapeshellarg($token)
. " --api-base " . escapeshellarg($apiBase)
. " --repo " . escapeshellarg($repo)
. " --branch " . escapeshellarg($branch) . " 2>&1");
// Build and upload package
$packageOutput = [];
exec("{$php} {$cli}/release_package.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($releaseVersion)
. " --tag " . escapeshellarg($releaseTag)
. " --token " . escapeshellarg($token)
. " --api-base " . escapeshellarg($apiBase)
. " --repo " . escapeshellarg($repo)
. " --output /tmp 2>&1", $packageOutput);
foreach ($packageOutput as $line) {
echo $line . "\n";
// Extract SHA from output
if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $line, $m)) {
$sha256 = $m[1];
}
}
// Also check GITHUB_OUTPUT
$ghOutput = getenv('GITHUB_OUTPUT');
if ($ghOutput && file_exists($ghOutput)) {
$ghContent = file_get_contents($ghOutput);
if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $ghContent, $m)) {
$sha256 = $m[1];
}
}
} else {
echo "[DRY-RUN] Would build and upload {$releaseVersion} to {$releaseTag}\n";
}
// -- Step 4: No lesser stream copies --
echo "\n--- Step 4: Skipped (no lesser stream copies) ---\n";
if ($skipUpdateStream) {
echo "\n--- Step 5: Skipped (--skip-update-stream) ---\n";
echo "\n--- Step 6: Skipped (--skip-update-stream) ---\n";
} else {
// -- Step 5: Update ONLY this stream in updates.xml --
echo "\n--- Step 5: Update {$stability} stream in updates.xml ---\n";
$streamsToWrite = [$stability];
foreach ($streamsToWrite as $stream) {
$streamVersion = $releaseVersion;
$shaFlag = !empty($sha256) ? "--sha {$sha256}" : '';
echo " Writing {$stream} stream: {$streamVersion}\n";
if (!$this->dryRun) {
passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($streamVersion)
. " --stability " . escapeshellarg($stream)
. " --gitea-url " . escapeshellarg($giteaUrl)
. " --org " . escapeshellarg($org)
. " --repo " . escapeshellarg($repo)
. " {$shaFlag} 2>&1");
}
}
// -- Step 6: Commit updates.xml and sync to all branches --
echo "\n--- Step 6: Commit and sync updates.xml ---\n";
$root = realpath($path) ?: $path;
if (!$this->dryRun) {
$cdX = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$cdRt = $cdX . escapeshellarg($root);
$diffCheck = trim((string) @shell_exec(
$cdRt . " && git diff --quiet updates.xml"
. " 2>&1 && echo clean || echo dirty"
));
if ($diffCheck === 'dirty') {
@shell_exec($cdRt . " && git add updates.xml");
$chMsg = "chore: update channels for"
. " {$releaseVersion} [skip ci]";
@shell_exec(
$cdRt . " && git commit -m "
. escapeshellarg($chMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
@shell_exec(
$cdRt . " && git push origin "
. escapeshellarg($branch) . " 2>&1"
);
echo " Committed updates.xml\n";
}
// Sync to all branches
passthru("{$php} {$cli}/updates_xml_sync.php --path " . escapeshellarg($path)
. " --current " . escapeshellarg($branch) . " --all"
. " --version " . escapeshellarg($releaseVersion)
. " --token " . escapeshellarg($token)
. " --gitea-url " . escapeshellarg($giteaUrl)
. " --org " . escapeshellarg($org)
. " --repo " . escapeshellarg($repo) . " 2>&1");
} else {
echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n";
}
}
echo "\n=== Release published: {$releaseVersion} ===\n";
// Output for CI
$ghOutput = getenv('GITHUB_OUTPUT');
if ($ghOutput) {
file_put_contents($ghOutput, "version={$releaseVersion}\nbase_version={$baseVersion}\n", FILE_APPEND);
}
return 0;
} }
} }
// Restore primary release version in source files $app = new ReleasePublishCli();
if (!$dryRun && $stabilityIndex > 0) { exit($app->execute());
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
}
// -- Step 5: Update ALL streams in updates.xml --
echo "\n--- Step 5: Update updates.xml for ALL streams ---\n";
// Write entry for the primary stream and all lesser streams
$streamsToWrite = array_slice($allStabilities, 0, $stabilityIndex + 1);
foreach ($streamsToWrite as $stream) {
$streamVersion = $baseVersion . $suffixMap[$stream];
$shaFlag = !empty($sha256) ? "--sha {$sha256}" : '';
echo " Writing {$stream} stream: {$streamVersion}\n";
if (!$dryRun) {
passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($streamVersion)
. " --stability " . escapeshellarg($stream)
. " --gitea-url " . escapeshellarg($giteaUrl)
. " --org " . escapeshellarg($org)
. " --repo " . escapeshellarg($repo)
. " {$shaFlag} 2>&1");
}
}
// -- Step 6: Commit updates.xml and sync to all branches --
echo "\n--- Step 6: Commit and sync updates.xml ---\n";
$root = realpath($path) ?: $path;
if (!$dryRun) {
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet updates.xml 2>&1 && echo clean || echo dirty"));
if ($diffCheck === 'dirty') {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add updates.xml");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore: update channels for {$releaseVersion} [skip ci]")
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
echo " Committed updates.xml\n";
}
// Sync to all branches
passthru("{$php} {$cli}/updates_xml_sync.php --path " . escapeshellarg($path)
. " --current " . escapeshellarg($branch) . " --all"
. " --version " . escapeshellarg($releaseVersion)
. " --token " . escapeshellarg($token)
. " --gitea-url " . escapeshellarg($giteaUrl)
. " --org " . escapeshellarg($org)
. " --repo " . escapeshellarg($repo) . " 2>&1");
} else {
echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n";
}
echo "\n=== Release published: {$releaseVersion} ===\n";
// Output for CI
$ghOutput = getenv('GITHUB_OUTPUT');
if ($ghOutput) {
file_put_contents($ghOutput, "version={$releaseVersion}\nbase_version={$baseVersion}\n", FILE_APPEND);
}
exit(0);
+210 -227
View File
@@ -6,252 +6,235 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_validate.php * PATH: /cli/release_validate.php
* BRIEF: Pre-release validation version consistency, required files, manifest checks * BRIEF: Pre-release validation -- version consistency, required files, manifest checks
*
* Usage:
* php release_validate.php --path /repo --version 04.01.00
* php release_validate.php --path /repo --version 04.01.00 --platform joomla --output-summary
*
* Options:
* --path Repository root (default: .)
* --version Expected version string (required)
* --platform joomla|dolibarr|generic (default: joomla)
* --output-summary Write markdown table to $GITHUB_STEP_SUMMARY
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$platform = null;
$outputSummary = false;
$githubOutput = false;
foreach ($argv as $i => $arg) { use MokoCli\{CliFramework, SourceResolver};
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--platform' && isset($argv[$i + 1])) {
$platform = $argv[$i + 1];
}
if ($arg === '--output-summary') {
$outputSummary = true;
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
if ($version === null) { class ReleaseValidateCli extends CliFramework
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n");
exit(1);
}
$root = realpath($path) ?: $path;
// Auto-detect platform from manifest.xml if not specified
if ($platform === null) {
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$mContent = file_get_contents($manifestXml);
if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) {
$platform = trim($pm[1]);
}
}
// Normalize platform aliases
if (in_array($platform, ['waas-component'], true)) {
$platform = 'joomla';
}
if (in_array($platform, ['crm-module'], true)) {
$platform = 'dolibarr';
}
if ($platform === null) {
$platform = 'generic';
}
}
$pass = 0;
$fail = 0;
$warn = 0;
/** @var array<int, array{check: string, status: string, details: string}> */
$results = [];
/**
* Record a validation result.
*
* @param string $check Check name
* @param string $status PASS, FAIL, or WARN
* @param string $details Human-readable details
*/
function addResult(string $check, string $status, string $details): void
{ {
global $pass, $fail, $warn, $results; private int $pass = 0;
$results[] = ['check' => $check, 'status' => $status, 'details' => $details]; private int $fail = 0;
if ($status === 'PASS') { private int $warn = 0;
$pass++; private array $results = [];
} elseif ($status === 'FAIL') {
$fail++; protected function configure(): void
} elseif ($status === 'WARN') { {
$warn++; $this->setDescription('Pre-release validation -- version consistency, required files, manifest checks');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Expected version string', null);
$this->addArgument('--platform', 'joomla|dolibarr|generic', null);
$this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false);
$this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false);
} }
}
// 0. Source directory check protected function run(): int
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs"); {
if ($hasSource) { $path = $this->getArgument('--path');
addResult('Source directory', 'PASS', 'src/ or htdocs/ found'); $version = $this->getArgument('--version');
} else { $platform = $this->getArgument('--platform');
addResult('Source directory', 'WARN', 'No src/ or htdocs/ directory'); $outputSummary = (bool) $this->getArgument('--output-summary');
} $githubOutput = (bool) $this->getArgument('--github-output');
if ($version === null) {
// 1. README.md exists and contains VERSION $this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]");
if (!file_exists("{$root}/README.md")) { return 1;
addResult('README.md', 'FAIL', 'Not found');
} else {
$readme = file_get_contents("{$root}/README.md");
if (
preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) ||
strpos($readme, $version) !== false
) {
addResult('README.md version', 'PASS', "`{$version}` found");
} else {
addResult('README.md version', 'FAIL', "`{$version}` not found in README.md");
}
}
// 2. CHANGELOG.md exists with matching section
if (!file_exists("{$root}/CHANGELOG.md")) {
addResult('CHANGELOG.md', 'WARN', 'Not found');
} else {
$cl = file_get_contents("{$root}/CHANGELOG.md");
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl)) {
addResult('CHANGELOG.md version', 'PASS', "Section for `{$version}` found");
} else {
addResult('CHANGELOG.md version', 'WARN', "No section header for `{$version}`");
}
}
// 3. LICENSE file exists
$licenseFound = false;
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
if (file_exists("{$root}/{$lf}")) {
$licenseFound = true;
break;
}
}
addResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
// 4. Platform-specific checks
if ($platform === 'joomla') {
// Find XML manifest
$manifest = null;
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) {
continue;
} }
foreach (glob("{$dir}/*.xml") as $xmlFile) { $root = realpath($path) ?: $path;
$content = file_get_contents($xmlFile); if ($platform === null) {
if (strpos($content, '<extension') !== false) { $manifestXml = "{$root}/.mokogitea/manifest.xml";
$manifest = $xmlFile; if (file_exists($manifestXml)) {
break 2; $mContent = file_get_contents($manifestXml);
if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) {
$platform = trim($pm[1]);
}
}
if (in_array($platform, ['waas-component'], true)) {
$platform = 'joomla';
}
if (in_array($platform, ['crm-module'], true)) {
$platform = 'dolibarr';
}
if ($platform === null) {
$platform = 'generic';
} }
} }
} $hasSource = SourceResolver::resolveAbsolute($root) !== null;
if ($manifest === null) { SourceResolver::warnIfLegacy($root);
addResult('XML manifest', 'FAIL', 'No Joomla manifest found'); $srcDirName = SourceResolver::resolve($root);
} else { $this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? "{$srcDirName}/ found" : 'No source/ or src/ directory');
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) { if (!file_exists("{$root}/README.md")) {
$mVer = trim($m[1]); $this->addVResult('README.md', 'FAIL', 'Not found');
if ($mVer === $version) { } else {
addResult('Manifest version', 'PASS', "`{$mVer}` matches"); $readme = file_get_contents("{$root}/README.md");
$quotedVer = preg_quote($version, '/');
$readmeHasVer = preg_match(
'/VERSION:\s*' . $quotedVer . '/',
$readme
) || strpos($readme, $version) !== false;
$this->addVResult(
'README.md version',
$readmeHasVer ? 'PASS' : 'FAIL',
$readmeHasVer
? "`{$version}` found"
: "`{$version}` not found"
);
}
if (!file_exists("{$root}/CHANGELOG.md")) {
$this->addVResult('CHANGELOG.md', 'WARN', 'Not found');
} else {
$cl = file_get_contents("{$root}/CHANGELOG.md");
$clHasVer = preg_match(
'/^##\s.*' . preg_quote($version, '/') . '/m',
$cl
);
$this->addVResult(
'CHANGELOG.md version',
$clHasVer ? 'PASS' : 'WARN',
$clHasVer ? "Section found" : "No section header"
);
}
$licenseFound = false;
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
if (file_exists("{$root}/{$lf}")) {
$licenseFound = true;
break;
}
}
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
if ($platform === 'joomla') {
$manifest = null;
$srcAbs = SourceResolver::resolveAbsolute($root);
foreach (array_filter([$srcAbs, $root]) as $dir) {
if (!is_dir($dir)) {
continue;
} foreach (glob("{$dir}/*.xml") as $xmlFile) {
$content = file_get_contents($xmlFile);
if (strpos($content, '<extension') !== false) {
$manifest = $xmlFile;
break 2;
}
}
}
if ($manifest === null) {
$this->addVResult('XML manifest', 'FAIL', 'No Joomla manifest found');
} else { } else {
addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`"); $manifestContent = file_get_contents($manifest);
if (preg_match('/<version>([^<]+)<\/version>/', $manifestContent, $m)) {
$mVer = trim($m[1]);
$this->addVResult(
'Manifest version',
$mVer === $version ? 'PASS' : 'FAIL',
$mVer === $version
? "`{$mVer}` matches"
: "`{$mVer}` != `{$version}`"
);
} else {
$this->addVResult('Manifest version', 'FAIL', 'No <version> tag');
}
}
if (!file_exists("{$root}/updates.xml")) {
$this->addVResult('updates.xml', 'WARN', 'Not found');
} else {
$ux = file_get_contents("{$root}/updates.xml");
$uxHasVer = preg_match(
'/<version>' . preg_quote($version, '/')
. '<\/version>/',
$ux
);
$this->addVResult(
'updates.xml version',
$uxHasVer ? 'PASS' : 'FAIL',
$uxHasVer
? "`{$version}` found"
: "`{$version}` not found"
);
}
} elseif ($platform === 'dolibarr') {
$modFile = null;
foreach (SourceResolver::getCandidates() as $sd) {
$matches = glob("{$root}/{$sd}/mod*.class.php");
if (!empty($matches)) {
$modFile = $matches[0];
break;
}
}
if ($modFile === null) {
$this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
} else {
$mc = file_get_contents($modFile);
$dolPattern = "/\\\$this->version\s*=\s*'"
. preg_quote($version, '/') . "'/";
$dolMatch = preg_match($dolPattern, $mc);
$this->addVResult(
'Dolibarr version',
$dolMatch ? 'PASS' : 'FAIL',
$dolMatch
? "`{$version}` matches"
: "`{$version}` not found"
);
} }
} else {
addResult('Manifest version', 'FAIL', 'No <version> tag in manifest');
} }
if (file_exists("{$root}/composer.json")) {
$composer = json_decode(file_get_contents("{$root}/composer.json"), true);
if (isset($composer['version'])) {
$compMatch = $composer['version'] === $version;
$this->addVResult(
'composer.json version',
$compMatch ? 'PASS' : 'WARN',
$compMatch
? "`{$version}` matches"
: "`{$composer['version']}` != `{$version}`"
);
}
}
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
foreach ($this->results as $r) {
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
}
$table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n";
echo $table;
if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) {
file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND);
}
}
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
if ($ghOutput) {
file_put_contents(
$ghOutput,
"validation_pass={$this->pass}\n"
. "validation_fail={$this->fail}\n"
. "validation_warn={$this->warn}\n"
. "validation_platform={$platform}\n",
FILE_APPEND
);
}
}
return $this->fail > 0 ? 1 : 0;
} }
// updates.xml private function addVResult(string $check, string $status, string $details): void
if (!file_exists("{$root}/updates.xml")) { {
addResult('updates.xml', 'WARN', 'Not found'); $this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
} else { if ($status === 'PASS') {
$ux = file_get_contents("{$root}/updates.xml"); $this->pass++;
if (preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux)) { } elseif ($status === 'FAIL') {
addResult('updates.xml version', 'PASS', "`{$version}` found"); $this->fail++;
} else { } elseif ($status === 'WARN') {
addResult('updates.xml version', 'FAIL', "`{$version}` not in updates.xml"); $this->warn++;
}
}
} elseif ($platform === 'dolibarr') {
$modFile = null;
foreach (['src', 'htdocs'] as $sd) {
$pattern = "{$root}/{$sd}/mod*.class.php";
$matches = glob($pattern);
if (!empty($matches)) {
$modFile = $matches[0];
break;
}
}
if ($modFile === null) {
addResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
} else {
$mc = file_get_contents($modFile);
if (preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc)) {
addResult('Dolibarr version', 'PASS', "`{$version}` matches");
} else {
addResult('Dolibarr version', 'FAIL', "`{$version}` not found in " . basename($modFile));
} }
} }
} }
// 5. composer.json version (if present) $app = new ReleaseValidateCli();
if (file_exists("{$root}/composer.json")) { exit($app->execute());
$composer = json_decode(file_get_contents("{$root}/composer.json"), true);
if (isset($composer['version'])) {
if ($composer['version'] === $version) {
addResult('composer.json version', 'PASS', "`{$version}` matches");
} else {
addResult('composer.json version', 'WARN', "`{$composer['version']}` != `{$version}`");
}
}
}
// Output
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
foreach ($results as $r) {
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
}
$table .= "\n**Validation: {$pass} passed, {$fail} failed, {$warn} warnings**\n";
echo $table;
if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) {
file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND);
}
}
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"validation_pass={$pass}",
"validation_fail={$fail}",
"validation_warn={$warn}",
"validation_platform={$platform}",
];
if ($ghOutput) {
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
}
}
exit($fail > 0 ? 1 : 0);
+193 -172
View File
@@ -1,188 +1,209 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_verify.php * PATH: /cli/release_verify.php
* BRIEF: Verify a built release artifact — version, SHA256, disallowed files * BRIEF: Verify a built release artifact — version, SHA256, disallowed files
*
* Usage:
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --updates-xml updates.xml
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --output-summary
*
* Options:
* --zip-path Path to ZIP file (required)
* --version Expected version string (required)
* --platform joomla|dolibarr|generic (default: joomla)
* --updates-xml Path to updates.xml for SHA256 comparison
* --github-output Export verify_pass, verify_fail to $GITHUB_OUTPUT
* --output-summary Write markdown table to $GITHUB_STEP_SUMMARY
*/ */
declare(strict_types=1); declare(strict_types=1);
$zipPath = null; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$platform = 'joomla';
$updatesXml = null;
$githubOutput = false;
$outputSummary = false;
foreach ($argv as $i => $arg) { use MokoCli\CliFramework;
if ($arg === '--zip-path' && isset($argv[$i + 1])) $zipPath = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; class ReleaseVerifyCli extends CliFramework
if ($arg === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1]; {
if ($arg === '--updates-xml' && isset($argv[$i + 1])) $updatesXml = $argv[$i + 1]; private int $pass = 0;
if ($arg === '--github-output') $githubOutput = true; private int $fail = 0;
if ($arg === '--output-summary') $outputSummary = true; private int $warn = 0;
private array $results = [];
protected function configure(): void
{
$this->setDescription('Verify a built release artifact — version, SHA256, disallowed files');
$this->addArgument('--zip-path', 'Path to ZIP file (required)', '');
$this->addArgument('--version', 'Expected version string (required)', '');
$this->addArgument('--platform', 'joomla|dolibarr|generic', 'joomla');
$this->addArgument('--updates-xml', 'Path to updates.xml for SHA256 comparison', '');
$this->addArgument('--github-output', 'Export verify_pass, verify_fail to $GITHUB_OUTPUT', false);
$this->addArgument('--output-summary', 'Write markdown table to $GITHUB_STEP_SUMMARY', false);
}
protected function run(): int
{
$zipPath = $this->getArgument('--zip-path');
$version = $this->getArgument('--version');
$platform = $this->getArgument('--platform');
$updatesXml = $this->getArgument('--updates-xml');
$githubOutput = $this->getArgument('--github-output');
$outputSummary = $this->getArgument('--output-summary');
if ($zipPath === '' || $version === '') {
$this->log('ERROR', 'Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]');
return 1;
}
// 1. ZIP exists and is readable
if (!file_exists($zipPath) || !is_readable($zipPath)) {
$this->addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}");
} else {
$this->addResult('ZIP exists', 'PASS', basename($zipPath));
// 2. Extract ZIP
$tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid();
mkdir($tmpDir, 0755, true);
$zip = new \ZipArchive();
if ($zip->open($zipPath) !== true) {
$this->addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file');
} else {
$zip->extractTo($tmpDir);
$zip->close();
$this->addResult('ZIP extract', 'PASS', 'Extracted successfully');
// 3. Manifest version check (Joomla)
if ($platform === 'joomla') {
$manifest = null;
foreach (glob("{$tmpDir}/*.xml") as $xmlFile) {
$content = file_get_contents($xmlFile);
if (strpos($content, '<extension') !== false) {
$manifest = $xmlFile;
break;
}
}
if ($manifest !== null) {
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
$manifestVer = trim($m[1]);
if ($manifestVer === $version) {
$this->addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release");
} else {
$this->addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`");
}
} else {
$this->addResult('Manifest version', 'WARN', 'No <version> tag in manifest');
}
} else {
$this->addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP');
}
}
// 4. SHA256 vs updates.xml
$zipSha = hash_file('sha256', $zipPath);
if ($updatesXml !== '' && file_exists($updatesXml)) {
$uxContent = file_get_contents($updatesXml);
if (preg_match('/<sha256>([^<]+)<\/sha256>/', $uxContent, $m)) {
$expectedSha = trim($m[1]);
if ($zipSha === $expectedSha) {
$this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
} else {
$this->addResult(
'SHA256 vs updates.xml',
'FAIL',
"ZIP=`" . substr($zipSha, 0, 16)
. "...` updates.xml=`"
. substr($expectedSha, 0, 16) . "...`"
);
}
} else {
$this->addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
}
}
// 5. Disallowed files
$disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env'];
$found = [];
$rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS));
foreach ($rit as $file) {
$name = $file->getFilename();
if (in_array($name, $disallowed, true)) {
$found[] = $name;
}
}
if (count($found) > 0) {
$this->addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found)));
} else {
$this->addResult('Disallowed files', 'PASS', 'None found');
}
// 6. Non-vendor .min files
$minCount = 0;
$rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS));
foreach ($rit as $file) {
$rel = str_replace($tmpDir . '/', '', $file->getPathname());
if (strpos($rel, 'vendor/') !== false) {
continue;
}
if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) {
$minCount++;
}
}
if ($minCount > 0) {
$this->addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime");
} else {
$this->addResult('Non-vendor .min files', 'PASS', 'None shipped');
}
// Clean up
$rit = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$tmpDir,
\RecursiveDirectoryIterator::SKIP_DOTS
),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($rit as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($tmpDir);
}
}
// Output
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
foreach ($this->results as $r) {
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
}
$table .= "\n**Verification: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n";
echo $table;
if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) {
file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND);
}
}
if ($githubOutput) {
$outputFile = getenv('GITHUB_OUTPUT');
if ($outputFile) {
file_put_contents($outputFile, "verify_pass={$this->pass}\nverify_fail={$this->fail}\nverify_warn={$this->warn}\n", FILE_APPEND);
}
}
return $this->fail > 0 ? 1 : 0;
}
private function addResult(string $check, string $status, string $details): void
{
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') {
$this->pass++;
} elseif ($status === 'FAIL') {
$this->fail++;
} elseif ($status === 'WARN') {
$this->warn++;
}
}
} }
if ($zipPath === null || $version === null) { $app = new ReleaseVerifyCli();
fwrite(STDERR, "Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]\n"); exit($app->execute());
exit(1);
}
$pass = 0;
$fail = 0;
$warn = 0;
$results = [];
function addResult(string $check, string $status, string $details): void {
global $pass, $fail, $warn, $results;
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') $pass++;
elseif ($status === 'FAIL') $fail++;
elseif ($status === 'WARN') $warn++;
}
// 1. ZIP exists and is readable
if (!file_exists($zipPath) || !is_readable($zipPath)) {
addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}");
} else {
addResult('ZIP exists', 'PASS', basename($zipPath));
// 2. Extract ZIP
$tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid();
mkdir($tmpDir, 0755, true);
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file');
} else {
$zip->extractTo($tmpDir);
$zip->close();
addResult('ZIP extract', 'PASS', 'Extracted successfully');
// 3. Manifest version check (Joomla)
if ($platform === 'joomla') {
$manifest = null;
foreach (glob("{$tmpDir}/*.xml") as $xmlFile) {
$content = file_get_contents($xmlFile);
if (strpos($content, '<extension') !== false) {
$manifest = $xmlFile;
break;
}
}
if ($manifest !== null) {
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
$manifestVer = trim($m[1]);
if ($manifestVer === $version) {
addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release");
} else {
addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`");
}
} else {
addResult('Manifest version', 'WARN', 'No <version> tag in manifest');
}
} else {
addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP');
}
}
// 4. SHA256 vs updates.xml
$zipSha = hash_file('sha256', $zipPath);
if ($updatesXml !== null && file_exists($updatesXml)) {
$uxContent = file_get_contents($updatesXml);
if (preg_match('/<sha256>([^<]+)<\/sha256>/', $uxContent, $m)) {
$expectedSha = trim($m[1]);
if ($zipSha === $expectedSha) {
addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
} else {
addResult('SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`");
}
} else {
addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
}
}
// 5. Disallowed files
$disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env'];
$found = [];
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS));
foreach ($rit as $file) {
$name = $file->getFilename();
if (in_array($name, $disallowed, true)) {
$found[] = $name;
}
}
if (count($found) > 0) {
addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found)));
} else {
addResult('Disallowed files', 'PASS', 'None found');
}
// 6. Non-vendor .min files
$minCount = 0;
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS));
foreach ($rit as $file) {
$rel = str_replace($tmpDir . '/', '', $file->getPathname());
if (strpos($rel, 'vendor/') !== false) continue;
if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) {
$minCount++;
}
}
if ($minCount > 0) {
addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime");
} else {
addResult('Non-vendor .min files', 'PASS', 'None shipped');
}
// Clean up
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($rit as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($tmpDir);
}
}
// Output
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
foreach ($results as $r) {
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
}
$table .= "\n**Verification: {$pass} passed, {$fail} failed, {$warn} warnings**\n";
echo $table;
if ($outputSummary) {
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
if ($summaryFile) {
file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND);
}
}
if ($githubOutput) {
$outputFile = getenv('GITHUB_OUTPUT');
if ($outputFile) {
file_put_contents($outputFile, "verify_pass={$pass}\nverify_fail={$fail}\nverify_warn={$warn}\n", FILE_APPEND);
}
}
exit($fail > 0 ? 1 : 0);
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
* FILE INFORMATION
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/repo_wizard.php
* BRIEF: Interactive configuration wizard for new repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{CliFramework, Config, PlatformAdapterFactory};
/**
* Interactive repo setup wizard.
*
* Walks through platform selection, generates config files, workflows,
* and optionally creates the repo on Gitea via API.
*
* @see https://git.mokoconsulting.tech/MokoConsulting/mokocli/issues/145
*/
class RepoWizard extends CliFramework
{
private const PLATFORMS = [
'joomla' => 'Joomla extension (component, module, plugin, package)',
'dolibarr' => 'Dolibarr ERP module',
'nodejs' => 'Node.js / TypeScript project',
'python' => 'Python project',
'mcp-server' => 'MCP server (Model Context Protocol)',
'generic' => 'Generic PHP or multi-language project',
];
private const LICENSES = [
'GPL-3.0-or-later' => 'GNU General Public License v3',
'MIT' => 'MIT License',
'Apache-2.0' => 'Apache License 2.0',
'proprietary' => 'Proprietary / All rights reserved',
];
/** Collected wizard answers. */
private array $answers = [];
/** When true, skip all interactive prompts and use defaults. */
private bool $nonInteractive = false;
protected function configure(): void
{
$this->setDescription('Interactive configuration wizard for new repositories');
$this->addArgument('--path', 'Directory to generate files in', '.');
$this->addArgument('--create-remote', 'Create repo on Gitea via API', false);
$this->addArgument('--non-interactive', 'Use defaults (no prompts)', false);
}
protected function run(): int
{
$rawPath = $this->getArgument('--path', '.');
$targetPath = realpath($rawPath) ?: $rawPath;
$this->nonInteractive = (bool) $this->getArgument('--non-interactive', false);
// Validate target path
if (!is_dir($targetPath) && !@mkdir($targetPath, 0755, true)) {
$this->log('ERROR', "Target path does not exist and cannot be created: {$targetPath}");
return self::EXIT_USAGE;
}
$targetPath = realpath($targetPath) ?: $targetPath;
$this->section('MokoCli Repository Wizard');
// ── Gather info ──────────────────────────────────────────────
$this->answers['name'] = $this->ask('Repository name', basename($targetPath));
$this->answers['platform'] = $this->choose('Platform type', self::PLATFORMS, 'generic');
$this->answers['org'] = $this->ask('Organization', 'MokoConsulting');
$this->answers['description'] = $this->ask('Description', '');
$this->answers['license'] = $this->choose('License', self::LICENSES, 'GPL-3.0-or-later');
// ── Confirm ──────────────────────────────────────────────────
$this->section('Configuration Summary');
foreach ($this->answers as $key => $value) {
$this->log('INFO', sprintf(' %-12s %s', $key . ':', $value));
}
if (!$this->confirm('Proceed with these settings?', true)) {
$this->log('INFO', 'Wizard cancelled');
return 0;
}
// ── Generate files ───────────────────────────────────────────
$this->section('Generating files');
$generated = $this->generateFiles($targetPath);
foreach ($generated as $file) {
$this->status(true, $file);
}
// ── Create remote repo ───────────────────────────────────────
if ($this->getArgument('--create-remote', false)) {
$this->section('Creating remote repository');
$this->createRemoteRepo();
}
$this->log('INFO', '');
$this->log('INFO', 'Generated ' . count($generated) . " files in {$targetPath}");
$this->log('INFO', 'Next: git init && git add -A && git commit -m "chore: initial scaffold"');
return 0;
}
// ── File generation ──────────────────────────────────────────────
private function generateFiles(string $path): array
{
$platform = $this->answers['platform'];
$name = $this->answers['name'];
$generated = [];
// .editorconfig
$generated[] = $this->writeFile($path, '.editorconfig', $this->editorconfig());
// README.md
$generated[] = $this->writeFile($path, 'README.md', $this->readme());
// CHANGELOG.md
$generated[] = $this->writeFile($path, 'CHANGELOG.md', $this->changelog());
// LICENSE
if ($this->answers['license'] !== 'proprietary') {
$generated[] = $this->writeFile($path, 'LICENSE', "See SPDX: {$this->answers['license']}");
}
// Platform-specific configs
switch ($platform) {
case 'joomla':
case 'dolibarr':
case 'generic':
$generated[] = $this->writeFile($path, 'phpcs.xml', $this->phpcsXml());
$generated[] = $this->writeFile($path, 'phpstan.neon', $this->phpstanNeon());
$generated[] = $this->writeFile($path, 'composer.json', $this->composerJson());
break;
case 'nodejs':
case 'mcp-server':
$generated[] = $this->writeFile($path, 'package.json', $this->packageJson());
$generated[] = $this->writeFile($path, 'tsconfig.json', $this->tsconfigJson());
$generated[] = $this->writeFile($path, '.eslintrc.json', $this->eslintrc());
break;
case 'python':
$generated[] = $this->writeFile($path, 'pyproject.toml', $this->pyprojectToml());
$generated[] = $this->writeFile($path, 'requirements.txt', '');
break;
}
// .mokogitea/workflows
$generated[] = $this->writeFile($path, '.mokogitea/workflows/pr-check.yml',
"# PR check workflow — synced from mokocli templates\n# Run: moko sync to update\n");
// .gitignore
$generated[] = $this->writeFile($path, '.gitignore', $this->gitignore($platform));
// Source directory
$srcDir = in_array($platform, ['joomla', 'dolibarr', 'generic']) ? 'source' : 'src';
if (!is_dir("{$path}/{$srcDir}")) {
@mkdir("{$path}/{$srcDir}", 0755, true);
$generated[] = "{$srcDir}/";
}
return array_filter($generated);
}
private function writeFile(string $basePath, string $relativePath, string $content): ?string
{
$fullPath = $basePath . '/' . $relativePath;
$dir = dirname($fullPath);
if (file_exists($fullPath)) {
$this->log('DEBUG', " SKIP {$relativePath} (already exists)");
return null;
}
if ($this->dryRun) {
$this->log('INFO', "[dry-run] Would create {$relativePath}");
return $relativePath;
}
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
file_put_contents($fullPath, $content);
return $relativePath;
}
// ── Remote repo creation ─────────────────────────────────────────
private function createRemoteRepo(): void
{
try {
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $this->answers['org'];
if ($this->dryRun) {
$this->log('INFO', "[dry-run] Would create {$org}/{$this->answers['name']} on Gitea");
return;
}
$result = $adapter->createRepository($org, $this->answers['name'], [
'description' => $this->answers['description'],
'private' => false,
]);
$url = $result['html_url'] ?? "{$org}/{$this->answers['name']}";
$this->log('INFO', "Created: {$url}");
} catch (\Exception $e) {
$this->log('ERROR', "Failed to create remote repo: {$e->getMessage()}");
}
}
// ── Interactive helpers (respect --non-interactive) ─────────────
private function ask(string $prompt, string $default): string
{
if ($this->nonInteractive) {
return $default;
}
return $this->input($prompt, $default);
}
private function choose(string $prompt, array $options, string $default): string
{
if ($this->nonInteractive) {
return $default;
}
$keys = array_keys($options);
$labels = [];
foreach ($options as $key => $desc) {
$labels[] = "{$key}{$desc}";
}
$chosen = $this->select($prompt, $labels);
// Extract the key from "key — description"
$chosenKey = explode(' — ', $chosen, 2)[0] ?? $default;
return in_array($chosenKey, $keys, true) ? $chosenKey : $default;
}
// ── Template content ─────────────────────────────────────────────
private function editorconfig(): string
{
return <<<'CONF'
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
CONF;
}
private function readme(): string
{
$name = $this->answers['name'];
$desc = $this->answers['description'] ?: 'A Moko Consulting project.';
$license = $this->answers['license'];
return <<<MD
# {$name}
{$desc}
## License
{$license}
MD;
}
private function changelog(): string
{
return <<<MD
# Changelog
## [Unreleased]
### Added
- Initial project scaffold
MD;
}
private function composerJson(): string
{
$data = [
'name' => 'mokoconsulting/' . strtolower($this->answers['name']),
'description' => $this->answers['description'] ?: $this->answers['name'],
'type' => 'library',
'license' => $this->answers['license'],
'require' => ['php' => '>=8.1'],
'autoload' => ['psr-4' => new \stdClass()],
];
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
}
private function phpcsXml(): string
{
return <<<'XML'
<?xml version="1.0"?>
<ruleset name="MokoCli">
<rule ref="PSR12"/>
<file>source/</file>
<exclude-pattern>vendor/*</exclude-pattern>
</ruleset>
XML;
}
private function phpstanNeon(): string
{
return <<<'NEON'
parameters:
level: 6
paths:
- source/
NEON;
}
private function packageJson(): string
{
$data = [
'name' => '@mokoconsulting/' . strtolower($this->answers['name']),
'version' => '0.1.0',
'description' => $this->answers['description'] ?: $this->answers['name'],
'type' => 'module',
'scripts' => ['build' => 'tsc', 'start' => 'node dist/index.js'],
'devDependencies' => ['typescript' => '^5.0'],
];
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
}
private function tsconfigJson(): string
{
return <<<'JSON'
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"declaration": true
},
"include": ["src/**/*"]
}
JSON;
}
private function eslintrc(): string
{
return <<<'JSON'
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"]
}
JSON;
}
private function pyprojectToml(): string
{
$name = strtolower($this->answers['name']);
$desc = str_replace(['\\', '"'], ['\\\\', '\\"'], $this->answers['description'] ?: $this->answers['name']);
return <<<TOML
[project]
name = "{$name}"
version = "0.1.0"
description = "{$desc}"
requires-python = ">=3.10"
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
TOML;
}
private function gitignore(string $platform): string
{
$common = <<<'GI'
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
desktop.ini
# Logs
*.log
GI;
$extra = match ($platform) {
'joomla', 'dolibarr', 'generic' => "\n# PHP\nvendor/\n.phpunit.result.cache\n",
'nodejs', 'mcp-server' => "\n# Node\nnode_modules/\ndist/\n*.tsbuildinfo\n",
'python' => "\n# Python\n__pycache__/\n*.pyc\n.venv/\n*.egg-info/\n",
default => '',
};
return $common . $extra;
}
}
$app = new RepoWizard('repo_wizard');
exit($app->execute());
+114 -229
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,244 +8,128 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/scaffold_client.php * PATH: /cli/scaffold_client.php
* VERSION: 09.21.00 * VERSION: 09.38.00
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings * BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ScaffoldClient require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class ScaffoldClientCli extends CliFramework
{ {
private string $name = ''; protected function configure(): void
private string $org = ''; {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; $this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
private string $token = ''; $this->addArgument('--name', 'Client name', '');
private bool $dryRun = false; $this->addArgument('--org', 'Gitea organization', '');
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
}
public function run(): int protected function run(): int
{ {
$this->parseArgs(); $name = $this->getArgument('--name');
$org = $this->getArgument('--org');
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$token = $this->getArgument('--token');
if ($name === '' || $org === '' || $token === '') {
$this->log('ERROR', '--name, --org, and --token are required.');
return 1;
}
$repoName = 'client-waas-' . $name;
$this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}");
$this->log('INFO', "Gitea URL: {$giteaUrl}");
if ($this->dryRun) {
$this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
$this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}");
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
return 0;
}
$this->log('INFO', 'Step 1: Creating repo from template...');
$createPayload = json_encode([
'owner' => $org,
'name' => $repoName,
'description' => "{$name} WaaS site",
'private' => true,
'git_content' => true,
'topics' => true,
'labels' => true,
]);
$response = $this->apiRequest(
'POST',
"/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate",
$giteaUrl,
$token,
$createPayload
);
if ($response['code'] < 200 || $response['code'] >= 300) {
$this->log('ERROR', "Failed to create repo (HTTP {$response['code']}).");
return 1;
}
$this->log('INFO', "Repo created: {$org}/{$repoName}");
$this->log('INFO', 'Step 2: Updating repo description...');
$this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"]));
$this->log('INFO', 'Step 3: Creating dev branch from main...');
$response = $this->apiRequest(
'POST',
"/api/v1/repos/{$org}/{$repoName}/branches",
$giteaUrl,
$token,
json_encode([
'new_branch_name' => 'dev',
'old_branch_name' => 'main',
])
);
if ($response['code'] >= 200 && $response['code'] < 300) {
$this->log('INFO', 'Branch "dev" created from "main".');
} else {
$this->log('WARN', "Could not create dev branch (HTTP {$response['code']}).");
}
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
$this->log('INFO', 'Scaffold complete.');
return 0;
}
if ($this->name === '' || $this->org === '' || $this->token === '') private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void
{ {
$this->log('ERROR: --name, --org, and --token are required.'); fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\n"
$this->printUsage(); . "Navigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\n"
return 1; . "Set REPO VARIABLES:\n"
} . " DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n"
. " LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\n"
. "Set REPO SECRETS:\n"
. " DEV_SYNC_KEY, LIVE_SSH_KEY\n\n"
. "================================\n");
}
$repoName = 'client-waas-' . $this->name; private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array
{
$this->log("Scaffolding client repo: {$this->org}/{$repoName}"); $ch = curl_init();
$this->log("Gitea URL: {$this->giteaUrl}"); curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
if ($this->dryRun) curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
{ curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]);
$this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS'); if ($body !== null) {
$this->log("[DRY RUN] Repo: {$this->org}/{$repoName}"); curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
$this->log("[DRY RUN] Description: \"{$this->name} WaaS site\""); }
$this->log('[DRY RUN] Would create dev branch from main'); $responseBody = curl_exec($ch);
$this->printPostSetupInstructions($repoName); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
return 0; if (curl_errno($ch)) {
} $error = curl_error($ch);
curl_close($ch);
// Step 1: Create repo from template return ['code' => 0, 'body' => "cURL error: {$error}"];
$this->log('Step 1: Creating repo from template...'); }
curl_close($ch);
$createPayload = json_encode([ return ['code' => $httpCode, 'body' => $responseBody];
'owner' => $this->org, }
'name' => $repoName,
'description' => "{$this->name} WaaS site",
'private' => true,
'git_content' => true,
'topics' => true,
'labels' => true,
]);
$response = $this->apiRequest(
'POST',
"/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate",
$createPayload
);
if ($response['code'] < 200 || $response['code'] >= 300)
{
$this->log("ERROR: Failed to create repo (HTTP {$response['code']}).");
$this->log("Response: {$response['body']}");
return 1;
}
$this->log("Repo created: {$this->org}/{$repoName}");
// Step 2: Set repo description (already set via generate, but confirm)
$this->log('Step 2: Updating repo description...');
$updatePayload = json_encode([
'description' => "{$this->name} WaaS site",
]);
$response = $this->apiRequest(
'PATCH',
"/api/v1/repos/{$this->org}/{$repoName}",
$updatePayload
);
if ($response['code'] >= 200 && $response['code'] < 300)
{
$this->log('Description updated.');
}
else
{
$this->log("WARNING: Could not update description (HTTP {$response['code']}).");
}
// Step 3: Create dev branch from main
$this->log('Step 3: Creating dev branch from main...');
$branchPayload = json_encode([
'new_branch_name' => 'dev',
'old_branch_name' => 'main',
]);
$response = $this->apiRequest(
'POST',
"/api/v1/repos/{$this->org}/{$repoName}/branches",
$branchPayload
);
if ($response['code'] >= 200 && $response['code'] < 300)
{
$this->log('Branch "dev" created from "main".');
}
else
{
$this->log("WARNING: Could not create dev branch (HTTP {$response['code']}).");
$this->log("Response: {$response['body']}");
}
// Step 4: Print post-setup instructions
$this->printPostSetupInstructions($repoName);
$this->log('Scaffold complete.');
return 0;
}
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
for ($i = 1; $i < $count; $i++)
{
switch ($args[$i])
{
case '--name':
$this->name = $args[++$i] ?? '';
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break;
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--dry-run':
$this->dryRun = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
}
private function printUsage(): void
{
$this->log('Usage: scaffold_client.php --name <client-name> --org <gitea-org> --token <token> [options]');
$this->log('');
$this->log('Options:');
$this->log(' --name <name> Client name (e.g., "clarksvillefurs")');
$this->log(' --org <org> Gitea organization (e.g., "ClarksvilleFurs")');
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
$this->log(' --token <token> Gitea API token');
$this->log(' --dry-run Show what would be done without making changes');
$this->log(' --help, -h Show this help');
}
private function printPostSetupInstructions(string $repoName): void
{
$this->log('');
$this->log('=== POST-SETUP INSTRUCTIONS ===');
$this->log('');
$this->log("Navigate to: {$this->giteaUrl}/{$this->org}/{$repoName}/settings");
$this->log('');
$this->log('Set the following REPO VARIABLES (Settings > Actions > Variables):');
$this->log(' DEV_SYNC_HOST - Dev server hostname or IP');
$this->log(' DEV_SYNC_PORT - Dev server SSH port (default: 22)');
$this->log(' DEV_SYNC_USER - Dev server SSH username');
$this->log(' DEV_SYNC_PATH - Dev server deploy path');
$this->log(' LIVE_SSH_HOST - Live server hostname or IP');
$this->log(' LIVE_SSH_PORT - Live server SSH port (default: 22)');
$this->log(' LIVE_SSH_USER - Live server SSH username');
$this->log(' LIVE_SYNC_PATH - Live server deploy path');
$this->log('');
$this->log('Set the following REPO SECRETS (Settings > Actions > Secrets):');
$this->log(' DEV_SYNC_KEY - Private SSH key for dev server');
$this->log(' LIVE_SSH_KEY - Private SSH key for live server');
$this->log('');
$this->log('================================');
}
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
{
$url = $this->giteaUrl . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$this->token}",
]);
if ($body !== null)
{
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch))
{
$error = curl_error($ch);
curl_close($ch);
return ['code' => 0, 'body' => "cURL error: {$error}"];
}
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
} }
$app = new ScaffoldClient(); $app = new ScaffoldClientCli();
exit($app->run()); exit($app->execute());
+164 -159
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -7,175 +8,179 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/sync_rulesets.php * PATH: /cli/sync_rulesets.php
* BRIEF: Apply branch protection rules to all repos via platform adapter * BRIEF: Apply branch protection rules to all repos via platform adapter
*
* USAGE
* php cli/sync_rulesets.php # Apply to all repos
* php cli/sync_rulesets.php --repo MokoCRM # Single repo
* php cli/sync_rulesets.php --dry-run # Preview only
* php cli/sync_rulesets.php --delete # Remove then re-apply
*
* NOTE: On GitHub, this creates rulesets via the rulesets API.
* On Gitea, this creates branch_protections via the branch protection API.
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\Config; use MokoCli\CliFramework;
use MokoEnterprise\PlatformAdapterFactory; use MokoCli\Config;
use MokoCli\PlatformAdapterFactory;
$dryRun = in_array('--dry-run', $argv); class SyncRulesetsCli extends CliFramework
$deleteOld = in_array('--delete', $argv); {
protected function configure(): void
{
$this->setDescription('Apply branch protection rules to all repos via platform adapter');
$this->addArgument('--repo', 'Single repository name (default: all repos)', '');
$this->addArgument('--delete', 'Remove existing protections before re-applying', false);
}
$repoName = null; protected function run(): int
{
$repoName = $this->getArgument('--repo');
$deleteOld = $this->getArgument('--delete');
foreach ($argv as $i => $arg) { $config = Config::load();
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; } $adapter = PlatformAdapterFactory::create($config);
$org = $config->getString(
$adapter->getPlatformName() . '.organization',
'mokoconsulting-tech'
);
$platformName = $adapter->getPlatformName();
$ALWAYS_EXCLUDE = ['mokocli', '.github-private'];
// -- Protection rules (platform-agnostic format) --
$PROTECTIONS = [
[
'name' => 'MAIN — protect default branch',
'branch' => 'main',
'rules' => [
'required_reviews' => 1,
'dismiss_stale' => true,
'enforce_admins' => true,
'block_on_rejected' => true,
'whitelist_actions_user' => true,
],
],
[
'name' => 'VERSION — immutable snapshots',
'branch' => 'version/*',
'rules' => [
'required_reviews' => 0,
'enforce_admins' => true,
'whitelist_actions_user' => true,
],
],
[
'name' => 'DEV — prevent branch deletion',
'branch' => 'dev/*',
'rules' => [
'required_reviews' => 0,
'enforce_admins' => true,
'whitelist_actions_user' => true,
],
],
[
'name' => 'RC — prevent branch deletion',
'branch' => 'rc/*',
'rules' => [
'required_reviews' => 0,
'enforce_admins' => true,
'whitelist_actions_user' => true,
],
],
];
// -- Build repo list --
$repos = [];
if ($repoName !== '') {
$repos = [$repoName];
} else {
echo "Fetching repositories from {$org} ({$platformName})...\n";
$allRepos = $adapter->listOrgRepos($org, true); // skip archived
foreach ($allRepos as $r) {
if (!in_array($r['name'], $ALWAYS_EXCLUDE, true)) {
$repos[] = $r['name'];
}
}
sort($repos);
echo "Found " . count($repos) . " repositories\n\n";
}
$created = 0;
$skipped = 0;
$failed = 0;
foreach ($repos as $repo) {
echo "Processing {$repo}...\n";
// Check existing protections
$existing = $adapter->listBranchProtections($org, $repo);
$existingNames = [];
if (is_array($existing)) {
foreach ($existing as $bp) {
$bpName = $bp['name'] ?? $bp['branch_name'] ?? $bp['rule_name'] ?? '';
$bpId = $bp['id'] ?? null;
if ($bpName !== '') {
$existingNames[$bpName] = $bpId;
}
}
}
foreach ($PROTECTIONS as $protection) {
$pName = $protection['name'];
if ($deleteOld && isset($existingNames[$pName])) {
if (!$this->dryRun) {
try {
// Platform-specific deletion via raw API
$adapter->getApiClient()->delete(
"/repos/{$org}/{$repo}/" .
($platformName === 'github' ? 'rulesets' : 'branch_protections') .
"/{$existingNames[$pName]}"
);
} catch (\Exception $e) {
/* ignore delete errors */
}
}
echo " Deleted: {$pName}\n";
unset($existingNames[$pName]);
}
if (isset($existingNames[$pName])) {
echo " Exists: {$pName}\n";
$skipped++;
continue;
}
if ($this->dryRun) {
echo " (dry-run) would create: {$pName}\n";
$created++;
continue;
}
try {
$adapter->setBranchProtection($org, $repo, $protection['branch'], $protection['rules']);
echo " Created: {$pName}\n";
$created++;
} catch (\Exception $e) {
$msg = $e->getMessage();
if (str_contains($msg, '403')) {
echo " Skipped (needs Pro/paid plan): {$pName}\n";
$skipped++;
} else {
echo " Failed: {$pName}{$msg}\n";
$failed++;
}
}
}
echo "\n";
}
echo str_repeat('-', 50) . "\n";
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
return $failed > 0 ? 1 : 0;
}
} }
$config = Config::load(); $app = new SyncRulesetsCli();
$adapter = PlatformAdapterFactory::create($config); exit($app->execute());
$org = $config->getString(
$adapter->getPlatformName() . '.organization',
'mokoconsulting-tech'
);
$platformName = $adapter->getPlatformName();
$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private'];
// ── Protection rules (platform-agnostic format) ─────────────────────────
// On GitHub → rulesets API. On Gitea → branch_protections API.
$PROTECTIONS = [
[
'name' => 'MAIN — protect default branch',
'branch' => 'main',
'rules' => [
'required_reviews' => 1,
'dismiss_stale' => true,
'enforce_admins' => true,
'block_on_rejected' => true,
'whitelist_actions_user' => true,
],
],
[
'name' => 'VERSION — immutable snapshots',
'branch' => 'version/*',
'rules' => [
'required_reviews' => 0,
'enforce_admins' => true,
'whitelist_actions_user' => true,
],
],
[
'name' => 'DEV — prevent branch deletion',
'branch' => 'dev/*',
'rules' => [
'required_reviews' => 0,
'enforce_admins' => true,
'whitelist_actions_user' => true,
],
],
[
'name' => 'RC — prevent branch deletion',
'branch' => 'rc/*',
'rules' => [
'required_reviews' => 0,
'enforce_admins' => true,
'whitelist_actions_user' => true,
],
],
];
// ── Build repo list ─────────────────────────────────────────────────────
$repos = [];
if ($repoName) {
$repos = [$repoName];
} else {
echo "Fetching repositories from {$org} ({$platformName})...\n";
$allRepos = $adapter->listOrgRepos($org, true); // skip archived
foreach ($allRepos as $r) {
if (!in_array($r['name'], $ALWAYS_EXCLUDE, true)) {
$repos[] = $r['name'];
}
}
sort($repos);
echo "Found " . count($repos) . " repositories\n\n";
}
$created = 0;
$skipped = 0;
$failed = 0;
foreach ($repos as $repo) {
echo "Processing {$repo}...\n";
// Check existing protections
$existing = $adapter->listBranchProtections($org, $repo);
$existingNames = [];
if (is_array($existing)) {
foreach ($existing as $bp) {
$bpName = $bp['name'] ?? $bp['branch_name'] ?? $bp['rule_name'] ?? '';
$bpId = $bp['id'] ?? null;
if ($bpName !== '') {
$existingNames[$bpName] = $bpId;
}
}
}
foreach ($PROTECTIONS as $protection) {
$pName = $protection['name'];
if ($deleteOld && isset($existingNames[$pName])) {
if (!$dryRun) {
try {
// Platform-specific deletion via raw API
$adapter->getApiClient()->delete(
"/repos/{$org}/{$repo}/" .
($platformName === 'github' ? 'rulesets' : 'branch_protections') .
"/{$existingNames[$pName]}"
);
} catch (\Exception $e) { /* ignore delete errors */ }
}
echo " Deleted: {$pName}\n";
unset($existingNames[$pName]);
}
if (isset($existingNames[$pName])) {
echo " Exists: {$pName}\n";
$skipped++;
continue;
}
if ($dryRun) {
echo " (dry-run) would create: {$pName}\n";
$created++;
continue;
}
try {
$adapter->setBranchProtection($org, $repo, $protection['branch'], $protection['rules']);
echo " Created: {$pName}\n";
$created++;
} catch (\Exception $e) {
$msg = $e->getMessage();
if (str_contains($msg, '403')) {
echo " Skipped (needs Pro/paid plan): {$pName}\n";
$skipped++;
} else {
echo " Failed: {$pName}{$msg}\n";
$failed++;
}
}
}
echo "\n";
}
echo str_repeat('-', 50) . "\n";
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
exit($failed > 0 ? 1 : 0);
+169 -190
View File
@@ -1,209 +1,188 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: moko-platform.CLI * DEFGROUP: mokocli.CLI
* INGROUP: moko-platform * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/theme_lint.php * PATH: /cli/theme_lint.php
* BRIEF: Lint theme files CSS syntax, image sizes, hardcoded URLs * BRIEF: Lint theme files -- CSS syntax, image sizes, hardcoded URLs
*
* Usage:
* php theme_lint.php --path /repo
* php theme_lint.php --path /repo --max-image-kb 500
* php theme_lint.php --path /repo --github-output
*
* Options:
* --path Repository root (default: .)
* --max-image-kb Maximum image file size in KB (default: 500)
* --github-output Export results to $GITHUB_OUTPUT
* --strict Exit 1 on any warning (default: only on errors)
*/ */
declare(strict_types=1); declare(strict_types=1);
$path = '.'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$maxImageKb = 500;
$ghOutput = false;
$strict = false;
foreach ($argv as $i => $arg) { use MokoCli\{CliFramework, SourceResolver};
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--max-image-kb' && isset($argv[$i + 1])) $maxImageKb = (int)$argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
if ($arg === '--strict') $strict = true;
}
$root = realpath($path) ?: $path; class ThemeLintCli extends CliFramework
$errors = 0;
$warnings = 0;
// ── Find source directory ───────────────────────────────────────────────
$srcDir = null;
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; }
}
if ($srcDir === null) {
fwrite(STDERR, "No src/ or htdocs/ directory in {$root}\n");
exit(1);
}
echo "Theme Lint: {$srcDir}\n\n";
// ── Check 1: CSS syntax validation ──────────────────────────────────────
echo "--- CSS Syntax ---\n";
$cssFiles = findFiles($srcDir, '*.css');
$cssMinFiles = findFiles($srcDir, '*.min.css');
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
if (empty($cssToCheck)) {
echo " No CSS files to check\n";
} else {
foreach ($cssToCheck as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
// Check for unmatched braces
$openBraces = substr_count($content, '{');
$closeBraces = substr_count($content, '}');
if ($openBraces !== $closeBraces) {
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
$errors++;
}
// Check for empty rules
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
$count = count($m[0]);
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
$warnings++;
}
// Check for !important abuse (more than 10 in one file)
$importantCount = substr_count($content, '!important');
if ($importantCount > 10) {
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
$warnings++;
}
}
if ($errors === 0) {
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
}
}
// ── Check 2: Image file sizes ───────────────────────────────────────────
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
$images = [];
foreach ($imageExts as $ext) {
$images = array_merge($images, findFiles($srcDir, $ext));
}
// Also check root images/ directory
if (is_dir("{$root}/images")) {
foreach ($imageExts as $ext) {
$images = array_merge($images, findFiles("{$root}/images", $ext));
}
}
$oversized = 0;
$totalSize = 0;
foreach ($images as $file) {
$size = filesize($file);
$totalSize += $size;
$relPath = str_replace($root . '/', '', $file);
$sizeKb = round($size / 1024);
if ($sizeKb > $maxImageKb) {
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
$oversized++;
$warnings++;
}
}
$totalMb = round($totalSize / 1024 / 1024, 1);
echo " " . count($images) . " image(s), {$totalMb}MB total";
if ($oversized > 0) {
echo ", {$oversized} oversized";
}
echo "\n";
// ── Check 3: Hardcoded URLs in CSS/JS ───────────────────────────────────
echo "\n--- Hardcoded URLs ---\n";
$codeFiles = array_merge(
findFiles($srcDir, '*.css'),
findFiles($srcDir, '*.js')
);
// Exclude minified files
$codeFiles = array_filter($codeFiles, function($f) {
return !preg_match('/\.min\.(css|js)$/', $f);
});
$urlPatterns = [
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
'/https?:\/\/localhost/' => 'localhost reference',
];
$urlIssues = 0;
foreach ($codeFiles as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
foreach ($urlPatterns as $pattern => $desc) {
if (preg_match_all($pattern, $content, $matches)) {
$count = count($matches[0]);
echo " WARN: {$relPath}: {$count} {$desc}\n";
$urlIssues++;
$warnings++;
}
}
}
if ($urlIssues === 0) {
echo " OK: No hardcoded URLs found\n";
}
// ── Summary ─────────────────────────────────────────────────────────────
echo "\n=== Summary ===\n";
echo "Errors: {$errors}\n";
echo "Warnings: {$warnings}\n";
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
}
}
if ($errors > 0) {
exit(1);
}
if ($strict && $warnings > 0) {
exit(1);
}
exit(0);
// ── Helper: recursively find files matching a glob pattern ──────────────
function findFiles(string $dir, string $pattern): array
{ {
$results = []; protected function configure(): void
if (!is_dir($dir)) return $results; {
$this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
$this->addArgument('--strict', 'Exit 1 on any warning', false);
}
$iterator = new RecursiveIteratorIterator( protected function run(): int
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) {
); $path = $this->getArgument('--path');
$maxImageKb = (int) $this->getArgument('--max-image-kb');
$ghOutput = (bool) $this->getArgument('--github-output');
$strict = (bool) $this->getArgument('--strict');
foreach ($iterator as $file) { $root = realpath($path) ?: $path;
if (fnmatch($pattern, $file->getFilename())) { $errors = 0;
$results[] = $file->getPathname(); $warnings = 0;
}
}
return $results; $srcDir = SourceResolver::resolveAbsolute($root);
if ($srcDir === null) {
$this->log('ERROR', "No source/ or src/ directory in {$root}");
return 1;
}
SourceResolver::warnIfLegacy($root);
echo "Theme Lint: {$srcDir}\n\n";
echo "--- CSS Syntax ---\n";
$cssFiles = $this->findFiles($srcDir, '*.css');
$cssMinFiles = $this->findFiles($srcDir, '*.min.css');
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
if (empty($cssToCheck)) {
echo " No CSS files to check\n";
} else {
foreach ($cssToCheck as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
$openBraces = substr_count($content, '{');
$closeBraces = substr_count($content, '}');
if ($openBraces !== $closeBraces) {
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
$errors++;
}
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
$count = count($m[0]);
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
$warnings++;
}
$importantCount = substr_count($content, '!important');
if ($importantCount > 10) {
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
$warnings++;
}
}
if ($errors === 0) {
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
}
}
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
$images = [];
foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles($srcDir, $ext));
}
if (is_dir("{$root}/images")) {
foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles("{$root}/images", $ext));
}
}
$oversized = 0;
$totalSize = 0;
foreach ($images as $file) {
$size = filesize($file);
$totalSize += $size;
$relPath = str_replace($root . '/', '', $file);
$sizeKb = round($size / 1024);
if ($sizeKb > $maxImageKb) {
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
$oversized++;
$warnings++;
}
}
$totalMb = round($totalSize / 1024 / 1024, 1);
echo " " . count($images) . " image(s), {$totalMb}MB total";
if ($oversized > 0) {
echo ", {$oversized} oversized";
}
echo "\n";
echo "\n--- Hardcoded URLs ---\n";
$codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js'));
$codeFiles = array_filter($codeFiles, function ($f) {
return !preg_match('/\.min\.(css|js)$/', $f);
});
$urlPatterns = [
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
'/https?:\/\/localhost/' => 'localhost reference',
];
$urlIssues = 0;
foreach ($codeFiles as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
foreach ($urlPatterns as $pattern => $desc) {
if (preg_match_all($pattern, $content, $matches)) {
$count = count($matches[0]);
echo " WARN: {$relPath}: {$count} {$desc}\n";
$urlIssues++;
$warnings++;
}
}
}
if ($urlIssues === 0) {
echo " OK: No hardcoded URLs found\n";
}
echo "\n=== Summary ===\n";
echo "Errors: {$errors}\n";
echo "Warnings: {$warnings}\n";
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
}
}
if ($errors > 0) {
return 1;
}
if ($strict && $warnings > 0) {
return 1;
}
return 0;
}
private function findFiles(string $dir, string $pattern): array
{
$results = [];
if (!is_dir($dir)) {
return $results;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (fnmatch($pattern, $file->getFilename())) {
$results[] = $file->getPathname();
}
}
return $results;
}
} }
$app = new ThemeLintCli();
exit($app->execute());

Some files were not shown because too many files have changed in this diff Show More