Compare commits

252 Commits

Author SHA1 Message Date
jmiller 84259c6636 fix: auto-download pre-built release for empty submodule sub-packages
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 16s
Universal: PR Check / Branch Policy (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
Platform: mokocli CI / Gate 1: Code Quality (pull_request) Failing after 48s
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokocli 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
When a Joomla package has a sub-package that is a git submodule with an
empty or missing source directory (e.g. failed CI checkout), the packager
now falls back to downloading the latest stable release ZIP from the
submodule's Gitea remote.

Also supports pre-staged ZIPs in the output directory, allowing manual
or workflow-based pre-population of sub-package archives.

Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-28 14:33:53 -05:00
jmiller 282a56258c Merge pull request 'fix(security): prevent script injection in rc-revert workflow' (#324) from feature/harden-rc-revert-injection into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 55s
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (push) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokocli CI / CI Summary (push) Has been cancelled
2026-06-27 02:32:26 +00:00
gitea-actions[bot] 3972b91169 chore(version): auto-bump patch 09.38.05-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
2026-06-27 02:30:54 +00:00
jmiller 5885797728 fix(security): prevent shell/PHP script injection in rc-revert workflow
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
The PR head branch ref is attacker-controlled and was substituted via
${{ }} directly into the shell run block (and interpolated into php -r),
allowing command injection with secrets.MOKOGITEA_TOKEN in scope.

- Pass untrusted values through env (BRANCH/REPO/GITEA_URL/TOKEN), not
  ${{ }} template substitution into shell source
- Strict allowlist ^rc/[A-Za-z0-9._/-]+$ before any use
- PHP reads BRANCH via getenv() instead of string interpolation
2026-06-27 02:30:43 +00:00
gitea-actions[bot] 27c19ccbaa chore(version): auto-bump patch 09.38.04-dev [skip ci] 2026-06-23 17:59:22 +00:00
jmiller 8dcd3a6af3 chore: remove composer-publish.yml -- no longer needed
Universal: Sync Feature Branch Versions / Sync feature branches with dev (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 19s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 1m48s
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (push) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokocli CI / CI Summary (push) Has been cancelled
2026-06-23 17:58:56 +00:00
jmiller 12779900b4 Merge pull request 'feat: release_publish.php uses semver tags for non-Joomla platforms (#304)' (#311) from feature/304-release-publish into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
Universal: Sync Feature Branch Versions / Sync feature branches with dev (push) Has been skipped
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 2m24s
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (push) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokocli CI / CI Summary (push) Has been cancelled
2026-06-21 23:34:29 +00:00
jmiller 31d2e4939a feat: release_publish.php uses semver tags for non-Joomla platforms (#304)
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: PR Check / Branch Policy (pull_request) Successful in 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 16s
Universal: PR Check / Validate PR (pull_request) Failing after 17s
Platform: mokocli CI / Gate 1: Code Quality (pull_request) Failing after 1m32s
RC Revert / Rename rc/ back to dev/ (pull_request) Failing after 10m25s
Branch Cleanup / Delete merged branch (pull_request) Failing after 10m33s
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokocli 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
2026-06-21 23:33:57 +00:00
jmiller ad9b82cbe9 Merge pull request 'feat: create semver tags for non-Joomla repos on merge to main (#304)' (#308) from feature/304-semver-auto-release into dev
Universal: Sync Feature Branch Versions / Sync feature branches with dev (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 56s
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (push) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokocli CI / CI Summary (push) Has been cancelled
2026-06-21 15:16:04 +00:00
gitea-actions[bot] a8769c1cdc chore(version): auto-bump patch 09.38.03-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
2026-06-21 15:04:41 +00:00
jmiller ccd1b23bff feat: create semver tags for non-Joomla repos on merge to main (#304)
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Platform: mokocli CI / Gate 1: Code Quality (pull_request) Failing after 40s
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokocli 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
2026-06-21 15:04:05 +00:00
jmiller 8a6f476957 Merge pull request 'feat: skip pre-release auto-bump for non-Joomla repos (#304)' (#307) from feature/304-semver-tags into dev
Universal: Sync Feature Branch Versions / Sync feature branches with dev (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 41s
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (push) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokocli CI / CI Summary (push) Has been cancelled
2026-06-21 14:51:40 +00:00
gitea-actions[bot] 7083ad9f9b chore(version): auto-bump patch 09.38.02-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-06-21 14:48:21 +00:00
jmiller 660f6caaa2 feat: skip pre-release auto-bump for non-Joomla repos (#304)
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
2026-06-21 14:48:09 +00:00
gitea-actions[bot] 8fef6a3ce7 chore(version): pre-release bump to 09.38.01-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 26s
2026-06-21 06:21:25 +00:00
gitea-actions[bot] e678f31817 chore(version): auto-bump patch 09.37.08-dev [skip ci] 2026-06-21 06:21:15 +00:00
Jonathan Miller 3ef651e34d fix(version-bump): always run git tag scan, fix null log interpolation
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: mokocli CI / Gate 1: Code Quality (push) Failing after 38s
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (push) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokocli CI / CI Summary (push) Has been cancelled
2026-06-21 01:20:58 -05: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
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
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (push) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokocli CI / CI Summary (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokocli 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
2026-06-21 06:18:37 +00:00
Jonathan Miller 95880d3e44 chore: complete namespace cleanup — remove all mokoplatform/MokoStandards/MokoEnterprise refs
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
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: mokocli 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
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)
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
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
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
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
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
2026-06-21 05:59:37 +00:00
jmiller 11371cf2d0 feat: add Composer publish workflow for Gitea registry + Packagist
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 46s
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
2026-06-21 05:45:12 +00:00
jmiller 5948d599f2 chore: rename package to mokoconsulting/mokocli, update description
Platform: mokoplatform CI / Gate 1: Code Quality (push) Failing after 49s
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
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 1: Code Quality (push) Failing after 1m2s
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
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 1: Code Quality (push) Failing after 50s
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
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
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
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
- 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 1: Code Quality (push) Failing after 42s
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
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 1: Code Quality (push) Failing after 49s
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
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
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
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
- 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 1: Code Quality (push) Failing after 40s
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
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 1: Code Quality (push) Failing after 40s
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
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)
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
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
- 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 1: Code Quality (push) Failing after 1m2s
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
2026-06-21 02:49:47 +00:00
Jonathan Miller e7b2c1fba2 fix(version-bump): prevent dev from falling behind stable (#289)
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
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
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 1: Code Quality (push) Failing after 1m6s
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
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 1: Code Quality (push) Failing after 1m3s
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
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 1: Code Quality (push) Failing after 1m25s
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
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 1: Code Quality (push) Failing after 1m23s
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
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 1: Code Quality (push) Failing after 1m3s
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
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 1: Code Quality (push) Failing after 1m1s
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
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 1: Code Quality (push) Failing after 1m25s
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
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 1: Code Quality (push) Failing after 1m28s
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
2026-06-21 01:39:59 +00:00
Jonathan Miller c461cc5520 feat: sync feature branch versions with dev after bumps (#250)
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
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
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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
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-21 01:26:33 +00:00
gitea-actions[bot] 7a4358454d chore(release): build 09.30.00 [skip ci]
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
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-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
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
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-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 / 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
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: 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
- 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 1: Code Quality (push) Failing after 1m10s
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
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: 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
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-20 19:03:55 +00:00
Moko Consulting 957460db03 fix: add missing src/functions.php referenced by composer autoload
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
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
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
2026-06-20 18:48:40 +00:00
jmiller 8b014ad0a9 feat: implement release zip cascade to lower stability channels (#280)
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
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-20 17:40:03 +00:00
jmiller f942615a12 fix: add branch+PR fallback for protected repos, rename moko-platform to mokocli
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
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-20 17:30:34 +00:00
jmiller 116d94dd8c fix: add branch+PR fallback for protected repos, rename moko-platform to mokocli
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
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-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: 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
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-20 17:15:50 +00:00
Jonathan Miller dc7f6c9eeb fix: improve error handling, add extension_type fallback (#259)
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
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
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
- 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: 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
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-20 16:00:42 +00:00
Jonathan Miller 463ae43e64 fix: rename Template-Client-Waas submodule to Template-Client
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
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
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
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: 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
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-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: 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
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-20 15:58:25 +00:00
Jonathan Miller 5521a7f81e refactor: replace template dirs with git submodules
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
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
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
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 / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
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
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
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: 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
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-20 15:30:20 +00:00
Jonathan Miller 12a16b178a chore: rename secret references for readability
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
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
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
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
738 changed files with 98593 additions and 18762 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
url: https://mokoconsulting.tech/
about: Get help or ask questions through our website
- name: 📚 moko-platform Documentation
url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
- name: 📚 mokocli Documentation
url: https://git.mokoconsulting.tech/MokoConsulting/mokocli
about: View our coding standards and best practices
- name: 🔒 Report a Security Vulnerability
url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new
+1 -1
View File
@@ -42,7 +42,7 @@ Suggested text here
<!-- Add any other context, screenshots, or references -->
## Standards Alignment
- [ ] Follows MokoStandards documentation guidelines
- [ ] Follows mokocli documentation guidelines
- [ ] Uses en_US/en_GB localization
- [ ] 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.
## 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)
- [ ] Localization (en_US/en_GB)
- [ ] Security best practices
+1 -1
View File
@@ -35,7 +35,7 @@ Use this template only for:
<!-- Describe how this could be addressed -->
## 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
- [ ] Secret management
- [ ] Dependency security
+6 -6
View File
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/branch-protection.yml
# BRIEF: Apply standardised branch protection rules to all governed repositories
#
@@ -57,13 +57,13 @@ jobs:
- name: Determine target repos
id: repos
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1"
# Platform/standards/infra repos to exclude
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private mokocli MokoTesting"
EXCLUDE="$EXCLUDE MokoCli-Template-Client MokoCli-Template-Dolibarr MokoCli-Template-Generic MokoCli-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then
# User-specified repos
@@ -105,7 +105,7 @@ jobs:
- name: Apply protection rules
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: |
API="${GITEA_URL}/api/v1"
+5 -5
View File
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/bulk-repo-sync.yml
# 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 }}"
php automation/bulk_sync.php ${{ steps.args.outputs.args }} 2>&1 | tee /tmp/bulk_sync.log
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GH_TOKEN: ${{ secrets.GH_PAT }}
GIT_PLATFORM: gitea
GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting
@@ -112,7 +112,7 @@ jobs:
bash automation/enforce_tags.sh || echo "Tag enforcement had errors (non-fatal)"
fi
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting
+3 -3
View File
@@ -2,9 +2,9 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: moko-platform.CI
# INGROUP: moko-platform
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# DEFGROUP: mokocli.CI
# INGROUP: mokocli
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/pr-branch-check.yml
# BRIEF: PR branch merge policy enforcement
#
+6 -6
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/renovate.yml
# BRIEF: Run Renovate Bot across all governed repos for dependency updates
#
@@ -57,12 +57,12 @@ jobs:
- name: Determine target repos
id: repos
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1"
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private mokocli MokoTesting"
EXCLUDE="$EXCLUDE MokoCli-Template-Client MokoCli-Template-Dolibarr MokoCli-Template-Generic MokoCli-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
@@ -107,7 +107,7 @@ jobs:
- name: Run Renovate
if: steps.repos.outputs.repo_list != ''
env:
RENOVATE_TOKEN: ${{ secrets.GA_TOKEN }}
RENOVATE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: ${{ env.GITEA_URL }}/api/v1
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@mokoconsulting.tech>'
+3 -3
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: mokocli.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.gitea/workflows/sync-wikis.yml
# BRIEF: Daily sync of all Gitea wikis to consolidated GitHub wiki repo
@@ -31,7 +31,7 @@ jobs:
- name: Sync all wikis
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GH_TOKEN: ${{ secrets.GH_PAT }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
if [ -z "$GH_TOKEN" ]; then
+66 -66
View File
@@ -1,66 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/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"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokocli tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+457 -270
View File
@@ -1,270 +1,457 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+
# | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
# | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +========================================================================+
name: "Universal: Build & Release"
on:
pull_request:
types: [opened, closed]
branches:
- main
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: false
type: choice
default: release
options:
- release
- promote-rc
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
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-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
- name: Rename branch to rc
run: |
php /tmp/moko-platform-api/cli/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
name: Build & Release Pipeline
runs-on: release
if: >-
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Configure git for bot pushes
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
# Ensure PHP + Composer are available
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# 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
- name: "Publish stable release"
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
# -- 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 /tmp/moko-platform-api/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 /tmp/moko-platform-api/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
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +=======================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +=======================================================================+
# | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
# | Platform-specific: |
# | joomla: XML manifest, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +=======================================================================+
name: "Universal: Build & Release"
on:
pull_request:
types: [opened, closed]
branches:
- main
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: false
type: choice
default: release
options:
- release
- promote-rc
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Rename branch to rc
run: |
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release
run: |
php ${MOKO_CLI}/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Update RC release notes from CHANGELOG.md
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Release candidate"
# Find the RC release and update its body
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/release-candidate" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "RC release notes updated from CHANGELOG.md"
fi
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
release:
name: Build & Release Pipeline
runs-on: release
if: >-
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Configure git for bot pushes
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found — aborting release"
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: "Detect platform"
id: platform
run: |
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
- name: "Determine version bump level"
id: bump
run: |
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
# Feature/dev branches: bump minor for the new stable release
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
case "$HEAD_REF" in
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
*) BUMP="minor" ;;
esac
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
- name: "Publish stable release"
run: |
BUMP_FLAG=""
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
fi
php ${MOKO_CLI}/release_publish.php \
--path . --stability stable ${BUMP_FLAG} --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: "Read published version"
id: version
run: |
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]]; then
echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
else
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
fi
echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}"
- name: "Create semver tag for non-Joomla repos"
id: semver
if: |
steps.version.outputs.skip != 'true' &&
!startsWith(steps.platform.outputs.platform, 'joomla')
run: |
VERSION="${{ steps.version.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
SEMVER_TAG="v${VERSION}"
echo "Creating semver tag: ${SEMVER_TAG}"
# Create the git tag via API
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/tags" \
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
echo "Created semver tag: ${SEMVER_TAG}"
elif [ "$HTTP_CODE" = "409" ]; then
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
else
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
fi
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
- name: Update release notes and promote changelog
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Get the stable release info (version and ID)
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
VERSION=$(python3 -c "
import json, sys, re
r = json.load(sys.stdin)
name = r.get('name', '')
m = re.search(r'(\d+\.\d+\.\d+)', name)
print(m.group(1) if m else '')
" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Stable release"
# Update release body via API
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
python3 -c "
import sys
version, date = sys.argv[1], sys.argv[2]
content = open('CHANGELOG.md').read()
old = '## [Unreleased]'
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
content = content.replace(old, new, 1)
open('CHANGELOG.md', 'w').write(content)
" "$VERSION" "$DATE"
git add CHANGELOG.md
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
git push origin main || true
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
--branch main 2>&1 || true
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- name: "Step 10: Push main to GitHub mirror"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
git fetch origin main --depth=1
git push github origin/main:refs/heads/main --force 2>/dev/null \
&& echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch and recreate dev from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+2 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: MokoCli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
+7 -210
View File
@@ -1,213 +1,10 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: 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
# DISABLED — auto-release Step 11 recreates dev from main after every release.
# Cascade-dev is redundant and causes version conflicts when both main and dev
# have different version numbers in templateDetails.xml / manifest.xml.
name: "Cascade Main → Dev (DISABLED)"
on: workflow_dispatch
jobs:
cascade:
name: Cascade main → branches
noop:
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip cascade]')
steps:
- name: Discover target branches
id: branches
env:
GA_TOKEN: ${{ secrets.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
- run: echo "Cascade disabled — auto-release handles dev recreation"
+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: MokoCli.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
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/ci-platform.yml
# VERSION: 01.00.00
# BRIEF: moko-platform CI — the standards engine validates itself
# INGROUP: mokocli.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/ci-platform.yml
# VERSION: 09.23.00
# 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 |
# | 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: |
# | |
@@ -29,7 +29,7 @@
# | |
# +========================================================================+
name: "Platform: moko-platform CI"
name: "Platform: mokocli CI"
on:
push:
@@ -41,7 +41,7 @@ on:
paths-ignore:
- '**.md'
- 'wiki/**'
- '.gitea/ISSUE_TEMPLATE/**'
- '.mokogitea/ISSUE_TEMPLATE/**'
pull_request:
branches:
- main
@@ -104,7 +104,7 @@ jobs:
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
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"
@@ -270,7 +270,7 @@ jobs:
echo "::warning file=${file}::Missing SPDX header"
MISSING=$((MISSING + 1))
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"
@@ -289,7 +289,7 @@ jobs:
echo "::error file=${file}::Potential hardcoded secret detected"
FOUND=$((FOUND + 1))
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"
@@ -412,10 +412,16 @@ jobs:
if: always()
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: Check gate results
run: |
{
echo "# moko-platform CI"
echo "# mokocli CI"
echo ""
echo "| Gate | Job | Status |"
echo "|---|---|---|"
@@ -437,3 +443,46 @@ jobs:
echo "::error::One or more CI gates failed"
exit 1
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
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: MokoCli.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
@@ -33,17 +33,17 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
token: ${{ secrets.GA_TOKEN }}
- name: Delete merged branches
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0
@@ -56,7 +56,7 @@ jobs:
# Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1))
fi
@@ -66,20 +66,20 @@ jobs:
- name: Clean old workflow runs
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0
for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
+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: MokoCli.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# 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 MokoCli tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
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}/mokocli.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
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# INGROUP: MokoCli.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
@@ -25,10 +25,6 @@
name: "Universal: Secret Scanning"
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
+3 -3
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 09.21.00
# INGROUP: mokocli.Automation
# VERSION: 09.38.05
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
+2 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: MokoCli.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
+301 -3
View File
@@ -4,10 +4,10 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# INGROUP: mokocli.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
@@ -96,6 +96,32 @@ jobs:
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Secret Scanning ──────────────────────────────────────────────────
gitleaks:
name: Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
- name: Scan PR commits for secrets
run: |
if gitleaks detect --source . --verbose \
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Potential secrets detected in PR commits"
exit 1
fi
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
@@ -105,6 +131,19 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform
id: platform
run: |
@@ -134,6 +173,98 @@ jobs:
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Joomla JEXEC guard check
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
# Skip vendor, node_modules, and index.html stub files
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
# Check first 10 lines for JEXEC or JPATH guard
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
echo "::error file=${file}::Missing JEXEC guard: ${file}"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
if [ "$ERRORS" -gt 0 ]; then
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "JEXEC guard: OK"
- name: Joomla directory listing protection
if: steps.platform.outputs.platform == 'joomla'
run: |
MISSING=0
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && exit 0
while IFS= read -r dir; do
if [ ! -f "${dir}/index.html" ]; then
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
MISSING=$((MISSING + 1))
fi
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
if [ "$MISSING" -gt 0 ]; then
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
fi
echo "Directory protection: ${MISSING} missing (advisory)"
- name: Joomla script file and asset checks
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && exit 0
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check scriptfile exists if declared
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
if [ -n "$SCRIPTFILE" ]; then
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
ERRORS=$((ERRORS + 1))
else
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
fi
fi
# Require joomla.asset.json and validate it
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ASSET_JSON" ]; then
echo "::error::joomla.asset.json not found — Joomla asset system is required"
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
echo "::error::joomla.asset.json is not valid JSON"
ERRORS=$((ERRORS + 1))
}
fi
echo "joomla.asset.json: valid"
fi
# Validate all XML files in src/ are well-formed
XML_ERRORS=0
if command -v php &> /dev/null; then
while IFS= read -r -d '' xmlfile; do
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
XML_ERRORS=$((XML_ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
fi
if [ "$XML_ERRORS" -gt 0 ]; then
echo "::error::${XML_ERRORS} XML file(s) are malformed"
ERRORS=$((ERRORS + 1))
else
echo "XML well-formedness: OK"
fi
[ "$ERRORS" -gt 0 ] && exit 1
echo "Joomla asset checks: OK"
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
@@ -151,6 +282,13 @@ jobs:
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
# Block legacy raw/branch update server URLs on MokoGitea
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
if [ -n "$RAW_URLS" ]; then
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
echo "$RAW_URLS"
exit 1
fi
echo "Joomla manifest valid"
;;
dolibarr)
@@ -183,6 +321,138 @@ jobs:
;;
esac
- name: Validate Joomla language files
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
WARNINGS=0
# Require both en-GB and en-US language directories
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$LANG_ROOT" ]; then
echo "No language/ directory found — skipping"
exit 0
fi
if [ ! -d "$LANG_ROOT/en-GB" ]; then
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
ERRORS=$((ERRORS + 1))
fi
if [ ! -d "$LANG_ROOT/en-US" ]; then
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
ERRORS=$((ERRORS + 1))
fi
# Check that en-GB and en-US have matching .ini files
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
[ ! -f "$GB_INI" ] && continue
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
if [ ! -f "$US_INI" ]; then
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
ERRORS=$((ERRORS + 1))
fi
done
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
[ ! -f "$US_INI" ] && continue
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
if [ ! -f "$GB_INI" ]; then
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
ERRORS=$((ERRORS + 1))
fi
done
fi
# Find all .ini language files
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
if [ -z "$INI_FILES" ]; then
echo "No .ini language files found"
[ "$ERRORS" -gt 0 ] && exit 1
exit 0
fi
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
for FILE in $INI_FILES; do
FNAME=$(basename "$FILE")
LINENUM=0
SEEN_KEYS=""
while IFS= read -r line || [ -n "$line" ]; do
LINENUM=$((LINENUM + 1))
# Skip empty lines and comments
[ -z "$line" ] && continue
echo "$line" | grep -qE '^\s*;' && continue
echo "$line" | grep -qE '^\s*$' && continue
# Must match KEY="VALUE" format
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
ERRORS=$((ERRORS + 1))
continue
fi
# Extract key and check for duplicates
KEY=$(echo "$line" | sed 's/=.*//')
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
ERRORS=$((ERRORS + 1))
fi
SEEN_KEYS="${SEEN_KEYS}
${KEY}"
done < "$FILE"
echo " ${FILE}: checked ${LINENUM} lines"
done
# Cross-check en-GB vs en-US key consistency
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
for GB_FILE in "$GB_DIR"/*.ini; do
[ ! -f "$GB_FILE" ] && continue
FNAME=$(basename "$GB_FILE")
US_FILE="$US_DIR/$FNAME"
[ ! -f "$US_FILE" ] && continue
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
# Keys in en-GB but not en-US
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_US" ]; then
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
# Keys in en-US but not en-GB
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_GB" ]; then
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
done
fi
{
echo "### Language File Validation"
echo "| Metric | Count |"
echo "|---|---|"
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
echo "| Errors | ${ERRORS} |"
echo "| Warnings | ${WARNINGS} |"
} >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -gt 0 ]; then
echo "::error::Language validation failed with ${ERRORS} error(s)"
exit 1
fi
echo "Language files: OK (${WARNINGS} warning(s))"
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
@@ -234,3 +504,31 @@ jobs:
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."
+110 -75
View File
@@ -4,19 +4,26 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
name: "Universal: Pre-Release"
on:
pull_request:
types: [closed]
push:
branches:
- dev
- 'fix/**'
- 'patch/**'
- 'hotfix/**'
- 'bugfix/**'
- 'chore/**'
- alpha
- beta
- rc
workflow_dispatch:
inputs:
stability:
@@ -39,11 +46,11 @@ env:
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
github.event_name == 'push'
steps:
- name: Checkout
@@ -51,32 +58,62 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
- name: Setup moko-platform tools
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
# 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
id: platform
run: |
# Auto-detect and update platform if not set in manifest
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
- name: Resolve metadata and bump version
id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: |
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
development) SUFFIX="-dev"; TAG="development" ;;
@@ -85,20 +122,26 @@ jobs:
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Read current version (bump already handled by push workflow)
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
[ -z "$VERSION" ] && VERSION="00.00.01"
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
case "$STABILITY" in
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\)$//')
php ${MOKO_CLI}/version_set_platform.php \
--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
# 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
VERSION="${VERSION}${SUFFIX}"
fi
@@ -135,6 +178,7 @@ jobs:
- name: Create release
id: release
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
@@ -142,10 +186,47 @@ jobs:
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--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
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
else
NOTES="Release ${VERSION}"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
- name: Build package and upload
id: package
if: steps.eligibility.outputs.proceed == 'true'
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
@@ -155,57 +236,11 @@ jobs:
--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
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
# updates.xml is generated dynamically by MokoGitea license server
# No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+71
View File
@@ -0,0 +1,71 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: 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
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
REPO: ${{ github.repository }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
set -euo pipefail
# BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
fi
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
# 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
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
ENCODED=$(php -r 'echo rawurlencode(getenv("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"
+54 -111
View File
@@ -7,11 +7,11 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# INGROUP: mokocli.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 04.06.00
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
# VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
name: "Generic: Repo Health"
@@ -24,26 +24,22 @@ on:
workflow_dispatch:
inputs:
profile:
description: 'Validation profile: all, release, scripts, or repo'
description: 'Validation profile: all, scripts, or repo'
required: true
default: all
type: choice
options:
- all
- release
- scripts
- repo
pull_request:
push:
branches:
- main
permissions:
contents: read
env:
# Release policy - Repository Variables Only
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
# Scripts governance policy
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
@@ -138,101 +134,6 @@ jobs:
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
exit 1
release_config:
name: Release configuration
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Guardrails release vars
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes release validation'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
missing=()
missing_optional=()
for k in "${required[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing+=("${k}")
done
for k in "${optional[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing_optional+=("${k}")
done
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Variable | Status |'
printf '%s\n' '|---|---|'
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repository variables'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#missing[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repository variables'
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
{
printf '%s\n' '### Repository variables validation result'
printf '%s\n' 'Status: OK'
printf '%s\n' 'All required repository variables present.'
printf '%s\n' ''
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
scripts_governance:
name: Scripts governance
needs: access_check
@@ -256,14 +157,14 @@ jobs:
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
all|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
if [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
@@ -370,14 +271,14 @@ jobs:
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
all|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
if [ "${profile}" = 'scripts' ]; then
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
@@ -704,7 +605,7 @@ jobs:
printf '%s\n' '| Domain | Status | Notes |'
printf '%s\n' '|---|---|---|'
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
printf '%s\n' '| Release variables | OK | Repository variables validation |'
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
@@ -767,3 +668,45 @@ jobs:
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
# ═══════════════════════════════════════════════════════════════════════
# Issue Reporter — file issues for failed gates
# ═══════════════════════════════════════════════════════════════════════
report-issues:
name: "Report Issues"
runs-on: ubuntu-latest
needs: [access_check, scripts_governance, repo_health]
if: >-
always() &&
(needs.scripts_governance.result == 'failure' ||
needs.repo_health.result == 'failure')
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issues for failed gates"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
REPORTER="./automation/ci-issue-reporter.sh"
WF="Repo Health"
report_gate() {
local gate="$1" result="$2" details="$3"
if [ "$result" = "failure" ]; then
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
fi
}
report_gate "Scripts Governance" \
"${{ needs.scripts_governance.result }}" \
"Scripts directory policy violations detected. Review required and allowed directories."
report_gate "Repository Health" \
"${{ needs.repo_health.result }}" \
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
+2 -18
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: MokoCli.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
@@ -80,19 +80,3 @@ jobs:
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${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": {
"generated_at": "2026-03-10T19:51:42.238134Z",
"repository": "mokoconsulting-tech/MokoStandards",
"repository": "MokoConsulting/mokocli",
"version": "1.0.0"
},
"scripts": [
+10 -8
View File
@@ -2,9 +2,9 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoStandards.Root
INGROUP: MokoStandards
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
DEFGROUP: MokoCli.Root
INGROUP: MokoCli
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
PATH: /CHANGELOG.md
BRIEF: Release changelog
-->
@@ -12,12 +12,14 @@ BRIEF: Release changelog
# Changelog
## [Unreleased]
## [09.21.00] --- 2026-05-30
## [09.37.00] --- 2026-06-21
## [09.20.00] --- 2026-05-30
## [09.37.00] --- 2026-06-21
## [09.19.00] --- 2026-05-30
## [09.36.00] --- 2026-06-21
## [09.18.00] --- 2026-05-30
## [09.36.00] --- 2026-06-21
## [09.17.00] --- 2026-05-30
## [09.35.00] --- 2026-06-21
## [09.35.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>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoStandards.Root
INGROUP: MokoStandards
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
DEFGROUP: MokoPlatform.Root
INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /PLUGIN_SCRIPTS.md
BRIEF: Plugin system CLI documentation
-->
# 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
+8 -8
View File
@@ -2,22 +2,22 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoStandards.Root
INGROUP: MokoStandards
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
DEFGROUP: MokoPlatform.Root
INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /README.md
VERSION: 09.21.00
VERSION: 09.38.05
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)
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)
> **Backup mirror**: [GitHub](https://github.com/MokoConsulting/MokoStandards-API) *(read-only mirror)*
> **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoCli-API)
> **Backup mirror**: [GitHub](https://github.com/MokoConsulting/MokoCli-API) *(read-only mirror)*
## What Lives Here
+3 -3
View File
@@ -2,9 +2,9 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoStandards.Index
INGROUP: MokoStandards.Analysis
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Analysis
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /analysis/index.md
BRIEF: Analysis directory index
-->
+7 -7
View File
@@ -9,9 +9,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Automation
* INGROUP: MokoStandards.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/bulk_joomla_template.php
* 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__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{
use MokoCli\{
AuditLogger,
CliFramework,
Config,
@@ -42,7 +42,7 @@ use MokoEnterprise\{
*
* Provides three operations for Joomla template projects:
* --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
*
* Works with both GitHub and Gitea via the PlatformAdapterFactory.
@@ -50,7 +50,7 @@ use MokoEnterprise\{
class BulkJoomlaTemplate extends CliFramework
{
public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '04.06.10';
public const VERSION = '09.23.00';
private GitPlatformAdapter $adapter;
private Config $config;
@@ -318,7 +318,7 @@ class BulkJoomlaTemplate extends CliFramework
$name,
$path,
$content,
"chore: update {$path} from MokoStandards",
"chore: update {$path} from mokocli",
$existingSha,
$branch
);
+44 -44
View File
@@ -9,9 +9,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Automation
* INGROUP: MokoStandards.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/bulk_sync.php
* BRIEF: Enterprise-grade bulk repository synchronization
*/
@@ -21,7 +21,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{
use MokoCli\{
ApiClient,
AuditLogger,
CheckpointManager,
@@ -42,7 +42,7 @@ use MokoEnterprise\{
/**
* 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.
*/
class BulkSync extends CliFramework
@@ -57,7 +57,7 @@ class BulkSync extends CliFramework
* Script version number
* 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';
private ApiClient $api;
@@ -95,7 +95,7 @@ class BulkSync extends CliFramework
*/
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
if (!$this->initializeComponents()) {
@@ -180,7 +180,7 @@ class BulkSync extends CliFramework
$results['health'] = $this->runHealthChecksAll($org, $repositories);
}
// Create/update tracking issue in MokoStandards
// Create/update tracking issue in mokocli
$this->createSyncIssue($org, $results);
// Create/update a failure issue when any repos failed
@@ -244,7 +244,7 @@ class BulkSync extends CliFramework
* Filter repositories based on include/exclude lists
*/
/** 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
{
@@ -426,7 +426,7 @@ class BulkSync extends CliFramework
$this->log("", 'ERROR');
$this->log("Required Implementation:", '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(" 4. Handle merge conflicts and validation", '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
* missing. This avoids the 422 "already exists" responses that would
@@ -872,7 +872,7 @@ class BulkSync extends CliFramework
// Workflow / Process
['automation', '8B4513', 'Automated processes or scripts'],
['mokostandards', 'B60205', 'MokoStandards compliance'],
['mokocli', 'B60205', 'mokocli compliance'],
['needs-review', 'FBCA04', 'Awaiting code review'],
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
@@ -912,8 +912,8 @@ class BulkSync extends CliFramework
['health: poor', 'FF6B6B', 'Health score below 50'],
// Sync / Automation (used by bulk_sync, scan_drift, check_repo_health)
['standards-update', 'B60205', 'MokoStandards sync update'],
['standards-drift', 'FBCA04', 'Repository drifted from MokoStandards'],
['standards-update', 'B60205', 'mokocli sync update'],
['standards-drift', 'FBCA04', 'Repository drifted from mokocli'],
['sync-report', '0075CA', 'Bulk sync run report'],
['sync-failure', 'D73A4A', 'Bulk sync 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'],
];
// 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.
try {
$probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
$probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokocli");
if (!empty($probe['name'])) {
return; // already provisioned
}
@@ -1024,7 +1024,7 @@ class BulkSync extends CliFramework
*/
private function updateOpenBranches(string $org, string $repo): void
{
$syncBranchPrefix = 'chore/sync-mokostandards-';
$syncBranchPrefix = 'chore/sync-mokocli-';
try {
$defaultBranch = 'main';
@@ -1055,7 +1055,7 @@ class BulkSync extends CliFramework
$this->api->post("/repos/{$org}/{$repo}/merges", [
'base' => $branch,
'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');
} catch (\Exception $e) {
@@ -1076,7 +1076,7 @@ class BulkSync extends CliFramework
/**
* 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.
*/
/**
@@ -1119,16 +1119,16 @@ class BulkSync extends CliFramework
$minor = self::VERSION_MINOR;
$force = isset($this->options['force']) ? ' *(--force)*' : '';
$prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber);
$source = $this->adapter->getRepoWebUrl($org, 'MokoStandards');
$branchName = 'chore/sync-mokostandards-v' . $minor;
$source = $this->adapter->getRepoWebUrl($org, 'mokocli');
$branchName = 'chore/sync-mokocli-v' . $minor;
$branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName);
$title = "chore: MokoStandards v{$minor} sync tracking";
$title = "chore: mokocli v{$minor} sync tracking";
$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 |
|-------|-------|
@@ -1144,13 +1144,13 @@ class BulkSync extends CliFramework
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;
// Dedent heredoc
$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);
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
{
@@ -1232,7 +1232,7 @@ class BulkSync extends CliFramework
$issues = $results['issues'] ?? [];
// 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'] ?? [];
$hasProtect = !empty($protection);
@@ -1281,7 +1281,7 @@ class BulkSync extends CliFramework
: "|---|---|---|---|";
$body = <<<MD
## MokoStandards Bulk Sync Report
## mokocli Bulk Sync Report
**Organisation:** `{$org}`
**Triggered:** {$now}{$force}
@@ -1301,7 +1301,7 @@ class BulkSync extends CliFramework
try {
// 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',
'state' => 'all',
'per_page' => 1,
@@ -1309,8 +1309,8 @@ class BulkSync extends CliFramework
'direction' => 'desc',
]);
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation'];
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames);
$labelNames = ['sync-report', 'mokocli', 'type: chore', 'automation'];
$labels = $this->resolveLabelIds($org, 'mokocli', $labelNames);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
@@ -1319,22 +1319,22 @@ class BulkSync extends CliFramework
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch);
$this->api->patch("/repos/{$org}/mokocli/issues/{$issueNumber}", $patch);
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) {
/* non-fatal */
}
$this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO');
$this->log("📋 Sync report issue updated: {$org}/mokocli#{$issueNumber}", 'INFO');
} else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
$issue = $this->api->post("/repos/{$org}/mokocli/issues", [
'title' => $title,
'body' => $body,
'labels' => $labels,
'assignees' => ['jmiller'],
]);
$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) {
$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.
* Reopens a closed issue rather than creating a duplicate.
*/
@@ -1388,7 +1388,7 @@ class BulkSync extends CliFramework
$body = preg_replace('/^ /m', '', $body);
try {
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [
$existing = $this->api->get("/repos/{$org}/mokocli/issues", [
'labels' => 'sync-failure',
'state' => 'all',
'per_page' => 1,
@@ -1403,17 +1403,17 @@ class BulkSync extends CliFramework
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN');
$this->api->patch("/repos/{$org}/mokocli/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/mokocli#{$num}", 'WARN');
} else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
$issue = $this->api->post("/repos/{$org}/mokocli/issues", [
'title' => $title,
'body' => $body,
'labels' => $this->resolveLabelIds($org, 'MokoStandards', ['sync-failure']),
'labels' => $this->resolveLabelIds($org, 'mokocli', ['sync-failure']),
'assignees' => ['jmiller'],
]);
$num = $issue['number'] ?? '?';
$this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN');
$this->log("🚨 Failure issue created: {$org}/mokocli#{$num}", 'WARN');
}
} catch (\Exception $e) {
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
+123 -123
View File
@@ -1,123 +1,123 @@
#!/usr/bin/env bash
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# BRIEF: Trigger a workflow across all client-waas repos in a Gitea org
set -euo pipefail
# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage: $(basename "$0") GITEA_URL TOKEN ORG WORKFLOW [REF] [INPUTS]
Arguments:
GITEA_URL Base URL of the Gitea instance (e.g. https://git.mokoconsulting.tech)
TOKEN Gitea API token with repo/action permissions
ORG Organisation or user that owns the repos
WORKFLOW Workflow filename to trigger (e.g. dependency-audit.yml)
REF Branch ref to run against (default: main)
INPUTS Optional JSON object of workflow inputs (e.g. '{"dry_run":"true"}')
Example:
$(basename "$0") https://git.mokoconsulting.tech abc123 MokoConsulting dependency-audit.yml main '{"notify":"true"}'
EOF
exit 1
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [ $# -lt 4 ]; then
usage
fi
GITEA_URL="${1%/}"
TOKEN="$2"
ORG="$3"
WORKFLOW="$4"
REF="${5:-main}"
INPUTS="${6:-{\}}"
# ---------------------------------------------------------------------------
# Fetch all repos in the org, paginated
# ---------------------------------------------------------------------------
echo "Fetching repos for org '${ORG}' on ${GITEA_URL} ..."
PAGE=1
LIMIT=50
ALL_REPOS=""
while true; do
RESPONSE=$(curl -s \
-H "Authorization: token ${TOKEN}" \
-H "Accept: application/json" \
"${GITEA_URL}/api/v1/orgs/${ORG}/repos?page=${PAGE}&limit=${LIMIT}")
# Break if empty array
COUNT=$(echo "$RESPONSE" | jq -r 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
NAMES=$(echo "$RESPONSE" | jq -r '.[].name')
ALL_REPOS="${ALL_REPOS}${NAMES}"$'\n'
if [ "$COUNT" -lt "$LIMIT" ]; then
break
fi
PAGE=$((PAGE + 1))
done
# ---------------------------------------------------------------------------
# Filter for client-waas repos
# ---------------------------------------------------------------------------
CLIENT_REPOS=$(echo "$ALL_REPOS" | grep 'client-waas' | sort || true)
if [ -z "$CLIENT_REPOS" ]; then
echo "No client-waas repos found in org '${ORG}'."
exit 0
fi
TOTAL=$(echo "$CLIENT_REPOS" | wc -l | tr -d ' ')
echo "Found ${TOTAL} client-waas repo(s). Triggering workflow '${WORKFLOW}' (ref: ${REF}) ..."
echo ""
# ---------------------------------------------------------------------------
# Trigger workflow for each repo
# ---------------------------------------------------------------------------
SUCCESS=0
FAIL=0
while IFS= read -r REPO; do
[ -z "$REPO" ] && continue
PAYLOAD=$(jq -n --arg ref "$REF" --argjson inputs "$INPUTS" '{ref: $ref, inputs: $inputs}')
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${ORG}/${REPO}/actions/workflows/${WORKFLOW}/dispatches")
if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 201 ]; then
echo " [OK] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
SUCCESS=$((SUCCESS + 1))
else
echo " [FAIL] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
FAIL=$((FAIL + 1))
fi
done <<< "$CLIENT_REPOS"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "Done. Success: ${SUCCESS} | Failed: ${FAIL} | Total: ${TOTAL}"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
#!/usr/bin/env bash
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# BRIEF: Trigger a workflow across all client-waas repos in a Gitea org
set -euo pipefail
# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage: $(basename "$0") GITEA_URL TOKEN ORG WORKFLOW [REF] [INPUTS]
Arguments:
GITEA_URL Base URL of the Gitea instance (e.g. https://git.mokoconsulting.tech)
TOKEN Gitea API token with repo/action permissions
ORG Organisation or user that owns the repos
WORKFLOW Workflow filename to trigger (e.g. dependency-audit.yml)
REF Branch ref to run against (default: main)
INPUTS Optional JSON object of workflow inputs (e.g. '{"dry_run":"true"}')
Example:
$(basename "$0") https://git.mokoconsulting.tech abc123 MokoConsulting dependency-audit.yml main '{"notify":"true"}'
EOF
exit 1
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [ $# -lt 4 ]; then
usage
fi
GITEA_URL="${1%/}"
TOKEN="$2"
ORG="$3"
WORKFLOW="$4"
REF="${5:-main}"
INPUTS="${6:-{\}}"
# ---------------------------------------------------------------------------
# Fetch all repos in the org, paginated
# ---------------------------------------------------------------------------
echo "Fetching repos for org '${ORG}' on ${GITEA_URL} ..."
PAGE=1
LIMIT=50
ALL_REPOS=""
while true; do
RESPONSE=$(curl -s \
-H "Authorization: token ${TOKEN}" \
-H "Accept: application/json" \
"${GITEA_URL}/api/v1/orgs/${ORG}/repos?page=${PAGE}&limit=${LIMIT}")
# Break if empty array
COUNT=$(echo "$RESPONSE" | jq -r 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
NAMES=$(echo "$RESPONSE" | jq -r '.[].name')
ALL_REPOS="${ALL_REPOS}${NAMES}"$'\n'
if [ "$COUNT" -lt "$LIMIT" ]; then
break
fi
PAGE=$((PAGE + 1))
done
# ---------------------------------------------------------------------------
# Filter for client-waas repos
# ---------------------------------------------------------------------------
CLIENT_REPOS=$(echo "$ALL_REPOS" | grep 'client-waas' | sort || true)
if [ -z "$CLIENT_REPOS" ]; then
echo "No client-waas repos found in org '${ORG}'."
exit 0
fi
TOTAL=$(echo "$CLIENT_REPOS" | wc -l | tr -d ' ')
echo "Found ${TOTAL} client-waas repo(s). Triggering workflow '${WORKFLOW}' (ref: ${REF}) ..."
echo ""
# ---------------------------------------------------------------------------
# Trigger workflow for each repo
# ---------------------------------------------------------------------------
SUCCESS=0
FAIL=0
while IFS= read -r REPO; do
[ -z "$REPO" ] && continue
PAYLOAD=$(jq -n --arg ref "$REF" --argjson inputs "$INPUTS" '{ref: $ref, inputs: $inputs}')
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${ORG}/${REPO}/actions/workflows/${WORKFLOW}/dispatches")
if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 201 ]; then
echo " [OK] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
SUCCESS=$((SUCCESS + 1))
else
echo " [FAIL] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
FAIL=$((FAIL + 1))
fi
done <<< "$CLIENT_REPOS"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "Done. Success: ${SUCCESS} | Failed: ${FAIL} | Total: ${TOTAL}"
if [ "$FAIL" -gt 0 ]; then
exit 1
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
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Automation
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/enrich_mokostandards_xml.php
* 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
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/
@@ -27,448 +19,466 @@
declare(strict_types=1);
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', '/');
$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
class EnrichMokostandardsXmlCli extends CliFramework
{
$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)];
}
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;
}
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', '');
}
// 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}";
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;
}
$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;
}
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
// 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]];
}
}
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
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)) {
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
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 (in_array($name, $skipRepos, true)) {
echo " {$name} ... SKIP (excluded)\n";
$stats['skipped']++;
continue;
}
if (str_contains($wc, 'src/')) {
$t['src_dir'] = 'src/';
if ($repo['archived'] ?? false) {
$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)) {
$enrichment['deploy'] = $targets;
@rmdir($tmpBase);
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
return 0;
}
// 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',
];
}
private function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
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;
}
}
}
}
if (!empty($scripts)) {
$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)));
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
}
if (isset($b['package_type'])) {
$build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$build->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)));
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'];
}
}
$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');
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 (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;
}
$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'])) {
$req->setAttribute('type', $d['type']);
$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';
}
$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($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);
if (!empty($targets)) {
$enrichment['deploy'] = $targets;
}
$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']);
$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',
];
}
}
}
$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 ─────────────────────────────────────────────────────────────────
echo "=== MokoStandards XML Manifest Enrichment ===\n";
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";
$app = new EnrichMokostandardsXmlCli();
exit($app->execute());
+3 -3
View File
@@ -2,9 +2,9 @@
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoStandards.Index
INGROUP: MokoStandards.Automation
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Automation
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /automation/index.md
BRIEF: Automation directory index
-->
+11 -11
View File
@@ -8,16 +8,16 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Automation
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/migrate_to_gitea.php
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
*
* USAGE
* php automation/migrate_to_gitea.php --dry-run
* 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
*/
@@ -25,12 +25,12 @@ declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\CheckpointManager;
use MokoEnterprise\CliFramework;
use MokoEnterprise\Config;
use MokoEnterprise\PlatformAdapterFactory;
use MokoEnterprise\GitHubAdapter;
use MokoEnterprise\MokoGiteaAdapter;
use MokoCli\CheckpointManager;
use MokoCli\CliFramework;
use MokoCli\Config;
use MokoCli\PlatformAdapterFactory;
use MokoCli\GitHubAdapter;
use MokoCli\MokoGiteaAdapter;
/**
* Gitea Migration Script
@@ -278,7 +278,7 @@ class MigrateToGitea extends CliFramework
try {
$this->gitea->createIssue(
$giteaOrg,
'MokoStandards',
'mokocli',
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
$report,
['labels' => ['automation', 'type: chore']]
+26 -25
View File
@@ -9,9 +9,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Automation
* INGROUP: MokoStandards.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_files.php
* 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__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\{
use MokoCli\{
ApiClient,
AuditLogger,
CliFramework,
@@ -35,7 +35,7 @@ use MokoEnterprise\{
/**
* 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.
*
* Files are specified by their destination path as they appear in the target
@@ -53,7 +53,7 @@ use MokoEnterprise\{
class PushFiles extends CliFramework
{
public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '04.06.00';
public const VERSION = '09.23.00';
private ApiClient $api;
private GitPlatformAdapter $adapter;
@@ -81,7 +81,7 @@ class PushFiles extends CliFramework
*/
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()) {
return 1;
@@ -230,7 +230,8 @@ class PushFiles extends CliFramework
{
// Read platform from repo's .mokogitea/manifest.xml via API
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)) {
$xml = @simplexml_load_string($manifestData);
if ($xml !== false) {
@@ -336,7 +337,7 @@ class PushFiles extends CliFramework
$prNumber = null;
if (!$direct) {
$prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards";
$prTitle = "chore: push " . count($entries) . " file(s) from mokocli";
$prBody = $this->buildPRBody($entries);
$pr = $this->adapter->createPullRequest(
$org,
@@ -413,7 +414,7 @@ class PushFiles extends CliFramework
$message = !empty($customMessage)
? $customMessage
: "chore: update {$destPath} from MokoStandards";
: "chore: update {$destPath} from mokocli";
// Fetch existing file SHA (needed for updates)
$existingSha = null;
@@ -456,9 +457,9 @@ class PushFiles extends CliFramework
): void {
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$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
? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |"
@@ -470,9 +471,9 @@ class PushFiles extends CliFramework
));
$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 |
|-------|-------|
@@ -486,12 +487,12 @@ class PushFiles extends CliFramework
{$fileRows}
---
*Generated automatically by [MokoStandards]({$source}) `push_files.php`*
*Generated automatically by [mokocli]({$source}) `push_files.php`*
MD;
$body = preg_replace('/^ /m', '', $body);
$labels = ['standards-update', 'mokostandards', 'type: chore', 'automation'];
$labels = ['standards-update', 'mokocli', 'type: chore', 'automation'];
try {
$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.
*/
private function createFailureIssue(string $org, array $results): void
@@ -597,7 +598,7 @@ class PushFiles extends CliFramework
$body = preg_replace('/^ /m', '', $body);
try {
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [
$existing = $this->api->get("/repos/{$org}/mokocli/issues", [
'labels' => 'push-failure',
'state' => 'all',
'per_page' => 1,
@@ -612,17 +613,17 @@ class PushFiles extends CliFramework
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN');
$this->api->patch("/repos/{$org}/mokocli/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/mokocli#{$num}", 'WARN');
} else {
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
$issue = $this->api->post("/repos/{$org}/mokocli/issues", [
'title' => $title,
'body' => $body,
'labels' => ['push-failure'],
'assignees' => ['jmiller'],
]);
$num = $issue['number'] ?? '?';
$this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN');
$this->log("🚨 Failure issue created: {$org}/mokocli#{$num}", 'WARN');
}
} catch (\Exception $e) {
$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
{
$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) {
$lines[] = "- `{$entry['destination']}`";
}
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'MokoStandards');
$lines[] = "\n---\n*Generated by [MokoStandards]({$sourceUrl}) `push_files.php`*";
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'mokocli');
$lines[] = "\n---\n*Generated by [mokocli]({$sourceUrl}) `push_files.php`*";
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
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Automation
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_mokostandards_xml.php
* 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);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\MokoStandardsParser;
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
// ── Configuration ────────────────────────────────────────────────────────
$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
class PushMokostandardsXmlCli extends CliFramework
{
global $CRM_PLATFORM_REPOS;
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
if (in_array($name, $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';
protected function configure(): void
{
$this->setDescription('Push XML manifests 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);
}
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';
}
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') ?: '';
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';
}
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
}
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
/**
* Safe shell execution — uses proc_open with explicit arguments to avoid injection.
* @return array{int, string}
*/
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)];
}
/** 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 "=== mokocli XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
}
@rmdir($dir);
}
echo "\n";
/**
* Run a git command safely in a given working directory.
* @return array{int, string}
*/
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;
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
$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'] 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 "=== MokoStandards XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
if (empty($token)) {
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;
return 0;
}
$platform = detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
// Prefer HTTPS with token (SSH port 2222 may be blocked); fall back to SSH
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
// Embed token in HTTPS URL for push auth
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
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'] ?? 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($name, self::CRM_PLATFORM_REPOS, true)) {
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('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';
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML .mokostandards manifest'
: 'chore: update .mokostandards to XML format';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
/**
* @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)];
}
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
foreach ($legacyDeleted as $lf) {
gitCmd($workDir, 'add', $lf);
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);
}
[$commitRet, $commitOut] = gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
rmTree($workDir);
continue;
/**
* @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);
}
[$pushRet, $pushOut] = 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']++;
}
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);
// Cleanup
rmTree($workDir);
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;
}
}
// 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";
$app = new PushMokostandardsXmlCli();
exit($app->execute());
+14 -14
View File
@@ -9,9 +9,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Automation
* INGROUP: MokoStandards.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/repo_cleanup.php
* 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__ . '/../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
@@ -38,15 +38,15 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAda
*/
class RepoCleanup extends CliFramework
{
private const VERSION = '04.06.00';
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
private const CURRENT_BRANCH = 'chore/sync-mokostandards-v04.02.00';
private const VERSION = '09.23.00';
private const SYNC_PREFIX = 'chore/sync-mokocli-';
private const CURRENT_BRANCH = 'chore/sync-mokocli-v04.02.00';
/** Workflow files that have been retired and should be deleted from governed repos. */
private const RETIRED_WORKFLOWS = [
'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.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',
'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.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("Current sync branch: " . self::CURRENT_BRANCH);
if ($this->dryRun) {
@@ -225,7 +225,7 @@ class RepoCleanup extends CliFramework
}
$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 ──────────────────────────────────────────────
@@ -463,9 +463,9 @@ class RepoCleanup extends CliFramework
private function checkLabels(string $org, string $repo, array &$results): void
{
try {
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
$this->api->get("/repos/{$org}/{$repo}/labels/mokocli");
} catch (\Exception $e) {
$this->logMsg(" ⚠️ Missing 'mokostandards' label");
$this->logMsg(" ⚠️ Missing 'mokocli' label");
$results['labels_missing']++;
$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)) {
$version = $m[1];
// Check .mokostandards for the tracked MokoStandards version
// Check manifest.xml for the tracked mokocli version
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'] ?? '');
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
if ($vm[1] !== self::VERSION) {
+3 -3
View File
@@ -4,9 +4,9 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# DEFGROUP: MokoStandards.Automation.ServerAutoheal
# INGROUP: MokoStandards.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# DEFGROUP: MokoPlatform.Automation.ServerAutoheal
# INGROUP: MokoPlatform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /automation/server-autoheal.sh
# 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.05
* 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
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.CLI
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: MokoCli.CLI
* INGROUP: MokoCli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* 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
* php bin/moko <command> [options] (all platforms)
* ./bin/moko <command> [options] (Unix, after: chmod +x bin/moko)
*
* COMMANDS
* sync Bulk-sync MokoStandards to organisation repos
* health Full repository health check (runs most validators)
* inventory Refresh docs/reference/REPOSITORY_INVENTORY.md
* COMMANDS (run `php bin/moko list` for the full list — 97 commands)
*
* check:syntax PHP syntax check (php -l) on all tracked .php files
* check:version Verify VERSION fields and badges match composer.json
* check:changelog Validate CHANGELOG.md format
* check:structure Verify required root files and directories
* check:headers Check SPDX-License-Identifier presence in source files
* check:secrets Scan for leaked credentials / API keys
* check:tabs Detect tab characters in YAML files
* check:paths Detect backslash path separators in PHP source
* check:xml Validate XML files are well-formed
* check:enterprise Full enterprise-readiness check (headers, strict types, PSR-12)
* check:dolibarr Validate Dolibarr module directory structure
* check:joomla Validate Joomla XML manifest
* check:language Validate Joomla/Dolibarr .ini language files
* detect Auto-detect repository platform type
* drift Scan org repos for drift from MokoStandards templates
* Automation sync, automation:cleanup, automation:migrate-gitea
* Validation health, detect, drift, check:syntax, check:version, ...
* Release release, release:joomla, release:create, release:publish, ...
* Version version:read, version:bump, version:auto-bump, ...
* Build build:package, build:joomla, build:updates-xml, ...
* Deploy deploy:joomla, deploy:dolibarr, deploy:sftp, deploy:rollback, ...
* Repository repo:create, repo:archive, repo:rename-branch, repo:reset-dev, ...
* Bulk Operations bulk:push-workflow, bulk:push-manifest, bulk:template-joomla, ...
* Maintenance maintenance:labels, maintenance:rotate-secrets, maintenance:pin-shas, ...
* Fix fix:line-endings, fix:tabs, fix:trailing, fix:permissions
* Monitoring dashboard, grafana, client:inventory, client:health-check
* Platform platform:detect, manifest:read, manifest:element
* Wiki wiki:sync
* Badges badge:update
*
* COMMON OPTIONS (passed through to each script)
* --path <dir> Repository root to check (default: .)
@@ -88,11 +84,23 @@ require_once $autoloader;
* All paths are relative to the repo root.
*/
const COMMAND_MAP = [
// Audit
'audit:query' => 'cli/audit_query.php',
// 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
'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
'health' => 'validate/check_repo_health.php',
@@ -108,11 +116,13 @@ const COMMAND_MAP = [
'check:enterprise' => 'validate/check_enterprise_readiness.php',
// Validation — platform-specific
'check:dolibarr' => 'validate/check_dolibarr_module.php',
'check:joomla' => 'validate/check_joomla_manifest.php',
'check:language' => 'validate/check_language_structure.php',
'check:client' => 'validate/check_client_theme.php',
'check:wiki' => 'validate/check_wiki_health.php',
'check:dolibarr' => 'validate/check_dolibarr_module.php',
'check:joomla' => 'validate/check_joomla_manifest.php',
'check:joomla-compat' => 'cli/joomla_compat_check.php',
'check:language' => 'validate/check_language_structure.php',
'check:client' => 'validate/check_client_theme.php',
'check:theme' => 'cli/theme_lint.php',
'check:wiki' => 'validate/check_wiki_health.php',
// Detection
'detect' => 'validate/auto_detect_platform.php',
@@ -124,13 +134,18 @@ const COMMAND_MAP = [
'release' => 'cli/release.php',
'release:notes' => 'cli/release_notes.php',
'release:validate' => 'cli/release_validate.php',
'manifest:element' => 'cli/manifest_element.php',
'release:cascade' => 'cli/release_cascade.php',
'release:promote' => 'cli/release_promote.php',
'release:create' => 'cli/release_create.php',
'release:manage' => 'cli/release_manage.php',
'release:mirror' => 'cli/release_mirror.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:promote' => 'cli/changelog_promote.php',
@@ -143,31 +158,76 @@ const COMMAND_MAP = [
'version:propagate' => 'maintenance/update_version_from_readme.php',
'version:set-platform' => 'cli/version_set_platform.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' => 'cli/package_build.php',
'build:joomla' => 'cli/joomla_build.php',
'build:updates-xml' => 'cli/updates_xml_build.php',
'build:package' => 'cli/package_build.php',
'build:joomla' => 'cli/joomla_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',
'manifest:read' => 'cli/manifest_read.php',
'manifest:element' => 'cli/manifest_element.php',
// Repository management
'repo:create' => 'cli/create_repo.php',
'repo:create-project' => 'cli/create_project.php',
'repo:archive' => 'cli/archive_repo.php',
'repo:scaffold-client' => 'cli/scaffold_client.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:push-workflow' => 'cli/bulk_workflow_push.php',
'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
'bulk:sync-rulesets' => 'cli/sync_rulesets.php',
'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
'bulk:trigger' => 'cli/bulk_workflow_trigger.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
'dashboard' => 'cli/client_dashboard.php',
'grafana' => 'cli/grafana_dashboard.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
'validate:module' => 'bin/validate-module',
@@ -197,16 +257,28 @@ if ($command === 'list' || $command === 'commands') {
// ── 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");
printCommandList();
exit(2);
}
$scriptPath = $repoRoot . '/' . COMMAND_MAP[$command];
$scriptPath = $repoRoot . '/' . $scriptRelative;
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");
exit(2);
}
@@ -226,10 +298,10 @@ function printHelp(): void
{
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
php bin/moko <command> [options] (all platforms)
@@ -268,6 +340,12 @@ function printCommandList(): void
'bulk' => 'Bulk Operations',
'client' => 'Client Management',
'validate' => 'Module Validation',
'deploy' => 'Deploy',
'fix' => 'Fix / Auto-remediation',
'maintenance' => 'Maintenance',
'automation' => 'Automation',
'badge' => 'Badges',
'wiki' => 'Wiki',
default => ucfirst($prefix),
};
} else {
@@ -277,6 +355,8 @@ function printCommandList(): void
'health' => 'Validation',
'detect', 'drift' => 'Validation',
'dashboard', 'grafana' => 'Monitoring',
'release' => 'Release',
'license' => 'Licensing',
default => 'Other',
};
}
@@ -323,7 +403,7 @@ function loadPluginCommands(): array
$commands = [];
foreach (glob("{$pluginDir}/*Plugin.php") as $file) {
$className = 'MokoEnterprise\\Plugins\\'
$className = 'MokoCli\\Plugins\\'
. pathinfo($file, PATHINFO_FILENAME);
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
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -7,139 +8,162 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/archive_repo.php
* 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);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\Config;
use MokoEnterprise\PlatformAdapterFactory;
use MokoCli\CliFramework;
use MokoCli\Config;
use MokoCli\PlatformAdapterFactory;
$dryRun = in_array('--dry-run', $argv);
$skipClose = in_array('--skip-close', $argv);
class ArchiveRepoCli extends CliFramework
{
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 ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; }
if (empty($repoName)) {
$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) {
fwrite(STDERR, "Usage: php archive_repo.php --repo <RepoName> [--skip-close] [--dry-run]\n");
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";
$app = new ArchiveRepoCli();
exit($app->execute());
+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
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/badge_update.php
* 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);
$path = '.';
$version = null;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
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];
use MokoCli\CliFramework;
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) {
fwrite(STDERR, "Usage: badge_update.php --path . --version XX.YY.ZZ\n");
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);
$app = new BadgeUpdateCli();
exit($app->execute());
+125 -115
View File
@@ -1,138 +1,148 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/branch_rename.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* 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);
$from = '';
$to = '';
$token = '';
$apiBase = '';
$prNum = '';
$dryRun = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
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;
}
use MokoCli\CliFramework;
if (empty($from) || empty($to) || empty($token) || empty($apiBase)) {
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
class BranchRenameCli extends CliFramework
{
$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));
protected function configure(): void
{
$this->setDescription('Rename a git branch via Gitea API (create new, update PR, delete old)');
$this->addArgument('--from', 'Source branch name', '');
$this->addArgument('--to', 'Target branch name', '');
$this->addArgument('--token', 'API token', '');
$this->addArgument('--api-base', 'API base URL', '');
$this->addArgument('--pr', 'PR number to update head branch', '');
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
protected function run(): int
{
$from = $this->getArgument('--from');
$to = $this->getArgument('--to');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$prNum = $this->getArgument('--pr');
return [
'code' => $httpCode,
'body' => json_decode($response ?: '{}', true) ?: [],
];
}
// 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 (empty($from) || empty($to) || empty($token) || empty($apiBase)) {
$this->log('ERROR', 'Usage: branch_rename.php --from BRANCH --to BRANCH --token TOKEN --api-base URL [--pr NUM] [--dry-run]');
return 1;
}
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
echo "Deleting old branch: {$from}\n";
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);
$app = new BranchRenameCli();
exit($app->execute());
+92 -170
View File
@@ -8,114 +8,129 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/bulk_workflow_push.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
*/
declare(strict_types=1);
final class BulkWorkflowPush
{
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;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class BulkWorkflowPushCli extends CliFramework
{
private int $updated = 0;
private int $created = 0;
private int $skipped = 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 === '') {
$this->log('ERROR: --token is required.');
$this->printUsage();
protected function run(): int
{
$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;
}
if ($this->workflowFile === '') {
$this->log('ERROR: --file is required.');
$this->printUsage();
if ($workflowFile === '') {
$this->log('ERROR', '--file is required.');
return 1;
}
if (!file_exists($this->workflowFile)) {
$this->log("ERROR: File not found: {$this->workflowFile}");
if (!file_exists($workflowFile)) {
$this->log('ERROR', "File not found: {$workflowFile}");
return 1;
}
if ($this->org === '') {
$this->log('ERROR: --org is required.');
$this->printUsage();
if ($org === '') {
$this->log('ERROR', '--org is required.');
return 1;
}
if ($this->destPath === '') {
$this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile);
if ($destPath === '') {
$destPath = '.mokogitea/workflows/' . basename($workflowFile);
}
$localContent = file_get_contents($this->workflowFile);
$localContent = file_get_contents($workflowFile);
if ($localContent === false) {
$this->log("ERROR: Could not read file: {$this->workflowFile}");
$this->log('ERROR', "Could not read file: {$workflowFile}");
return 1;
}
$this->log("Pushing: {$this->workflowFile}");
$this->log(" -> {$this->destPath} (branch: {$this->branch})");
$this->log(" -> Org: {$this->org} @ {$this->giteaUrl}");
$this->log('INFO', "Pushing: {$workflowFile}");
$this->log('INFO', " -> {$destPath} (branch: {$branch})");
$this->log('INFO', " -> Org: {$org} @ {$giteaUrl}");
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) {
return 1;
}
$this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\".");
$this->log('');
$this->log(sprintf('%-45s | %s', 'Repo', 'Status'));
$this->log(str_repeat('-', 70));
$this->log('INFO', "Found " . count($repos) . " repo(s) in \"{$org}\".");
echo "\n";
fprintf(STDERR, "%-45s | %s\n", 'Repo', 'Status');
fprintf(STDERR, "%s\n", str_repeat('-', 70));
$encodedContent = base64_encode($localContent);
foreach ($repos as $repo) {
$this->pushToRepo($repo, $encodedContent, $localContent);
$this->pushToRepo($giteaUrl, $token, $repo, $encodedContent, $localContent, $destPath, $branch);
}
$this->log('');
$this->log("Done: {$this->created} created, {$this->updated} updated, "
echo "\n";
$this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, "
. "{$this->skipped} skipped, {$this->errors} error(s).");
return $this->errors > 0 ? 1 : 0;
}
private function pushToRepo(
string $giteaUrl,
string $token,
string $repoFullName,
string $encodedContent,
string $localContent
string $localContent,
string $destPath,
string $branch
): void {
[$owner, $repoName] = explode('/', $repoFullName, 2);
$existing = $this->apiRequest(
$giteaUrl,
$token,
'GET',
"/api/v1/repos/{$owner}/{$repoName}/contents/"
. "{$this->destPath}?ref={$this->branch}"
. "{$destPath}?ref={$branch}"
);
if ($existing['code'] === 200) {
@@ -124,21 +139,13 @@ final class BulkWorkflowPush
$remoteContent = base64_decode($data['content'] ?? '');
if ($remoteContent === $localContent) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'IDENTICAL (skipped)'
));
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'IDENTICAL (skipped)');
$this->skipped++;
return;
}
if ($this->dryRun) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'WOULD UPDATE'
));
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD UPDATE');
$this->updated++;
return;
}
@@ -146,100 +153,82 @@ final class BulkWorkflowPush
$payload = json_encode([
'content' => $encodedContent,
'sha' => $remoteSha,
'message' => "chore: sync {$this->destPath} "
. "from moko-platform [skip ci]",
'branch' => $this->branch,
'message' => "chore: sync {$destPath} "
. "from mokocli [skip ci]",
'branch' => $branch,
]);
$response = $this->apiRequest(
$giteaUrl,
$token,
'PUT',
"/api/v1/repos/{$owner}/{$repoName}/contents/"
. $this->destPath,
. $destPath,
$payload
);
if ($response['code'] === 200) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'UPDATED'
));
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'UPDATED');
$this->updated++;
} else {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$response['code']})"
));
fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
$this->errors++;
}
} elseif ($existing['code'] === 404) {
if ($this->dryRun) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'WOULD CREATE'
));
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD CREATE');
$this->created++;
return;
}
$payload = json_encode([
'content' => $encodedContent,
'message' => "chore: add {$this->destPath} "
. "from moko-platform [skip ci]",
'branch' => $this->branch,
'message' => "chore: add {$destPath} "
. "from mokocli [skip ci]",
'branch' => $branch,
]);
$response = $this->apiRequest(
$giteaUrl,
$token,
'POST',
"/api/v1/repos/{$owner}/{$repoName}/contents/"
. $this->destPath,
. $destPath,
$payload
);
if ($response['code'] === 201) {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
'CREATED'
));
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'CREATED');
$this->created++;
} else {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$response['code']})"
));
fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
$this->errors++;
}
} else {
$this->log(sprintf(
'%-45s | %s',
$repoFullName,
"ERROR (HTTP {$existing['code']})"
));
fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$existing['code']})");
$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;
$repos = [];
while (true) {
$response = $this->apiRequest(
$giteaUrl,
$token,
'GET',
"/api/v1/orgs/{$this->org}/repos?"
"/api/v1/orgs/{$org}/repos?"
. "limit=50&page={$page}"
);
if ($response['code'] < 200 || $response['code'] >= 300) {
if ($page === 1) {
$this->log("ERROR: Could not fetch repos "
$this->log('ERROR', "Could not fetch repos "
. "(HTTP {$response['code']}).");
return null;
}
@@ -271,76 +260,14 @@ final class BulkWorkflowPush
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(
string $giteaUrl,
string $token,
string $method,
string $endpoint,
?string $body = null
): array {
$url = $this->giteaUrl . $endpoint;
$url = $giteaUrl . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
@@ -349,7 +276,7 @@ final class BulkWorkflowPush
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
"Authorization: token {$this->token}",
"Authorization: token {$token}",
]);
if ($body !== null) {
@@ -376,12 +303,7 @@ final class BulkWorkflowPush
return ['code' => $httpCode, 'body' => $responseBody];
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
}
$app = new BulkWorkflowPush();
exit($app->run());
$app = new BulkWorkflowPushCli();
exit($app->execute());
+178 -252
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -7,313 +8,238 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/bulk_workflow_trigger.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* BRIEF: Trigger a workflow across multiple repos at once
*/
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 $token = '';
private string $reposFile = '';
private string $org = '';
private string $workflow = '';
private string $ref = 'main';
private string $inputs = '';
private bool $dryRun = false;
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = '';
private string $reposFile = '';
private string $org = '';
private string $workflow = '';
private string $ref = 'main';
private string $inputs = '';
public function run(): int
{
$this->parseArgs();
protected function configure(): void
{
$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 === '')
{
$this->log('ERROR: --token is required.');
$this->printUsage();
return 1;
}
protected function run(): int
{
$this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$this->token = $this->getArgument('--token');
$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 === '')
{
$this->log('ERROR: --workflow is required.');
$this->printUsage();
return 1;
}
if ($this->token === '') {
$this->log('ERROR', '--token is required.');
return 1;
}
if ($this->reposFile === '' && $this->org === '')
{
$this->log('ERROR: Either --repos <file> or --org <org> is required.');
$this->printUsage();
return 1;
}
if ($this->workflow === '') {
$this->log('ERROR', '--workflow is required.');
return 1;
}
// Build repo list
$repos = $this->buildRepoList();
if ($this->reposFile === '' && $this->org === '') {
$this->log('ERROR', 'Either --repos <file> or --org <org> is required.');
return 1;
}
if ($repos === null || count($repos) === 0)
{
$this->log('ERROR: No repos found to process.');
return 1;
}
// Build repo list
$repos = $this->buildRepoList();
$this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s).");
$this->log("Gitea URL: {$this->giteaUrl}");
if ($repos === null || count($repos) === 0) {
$this->log('ERROR', 'No repos found to process.');
return 1;
}
if ($this->dryRun)
{
$this->log('[DRY RUN] No requests will be sent.');
}
$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('');
if ($this->dryRun) {
$this->log('INFO', '[DRY RUN] No requests will be sent.');
}
// Parse inputs
$inputsDecoded = null;
$this->log('INFO', '');
if ($this->inputs !== '')
{
$inputsDecoded = json_decode($this->inputs, true);
// Parse inputs
$inputsDecoded = null;
if (!is_array($inputsDecoded))
{
$this->log('ERROR: --inputs must be valid JSON.');
return 1;
}
}
if ($this->inputs !== '') {
$inputsDecoded = json_decode($this->inputs, true);
// Print header
$this->log(sprintf('%-40s | %s', 'Repo', 'Status'));
$this->log(str_repeat('-', 60));
if (!is_array($inputsDecoded)) {
$this->log('ERROR', '--inputs must be valid JSON.');
return 1;
}
}
$failCount = 0;
// Print header
$this->log('INFO', sprintf('%-40s | %s', 'Repo', 'Status'));
$this->log('INFO', str_repeat('-', 60));
foreach ($repos as $repo)
{
$repo = trim($repo);
$failCount = 0;
if ($repo === '' || strpos($repo, '/') === false)
{
continue;
}
foreach ($repos as $repo) {
$repo = trim($repo);
[$owner, $repoName] = explode('/', $repo, 2);
if ($repo === '' || strpos($repo, '/') === false) {
continue;
}
if ($this->dryRun)
{
$this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
continue;
}
[$owner, $repoName] = explode('/', $repo, 2);
$payload = ['ref' => $this->ref];
if ($this->dryRun) {
$this->log('INFO', sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
continue;
}
if ($inputsDecoded !== null)
{
$payload['inputs'] = $inputsDecoded;
}
$payload = ['ref' => $this->ref];
$response = $this->apiRequest(
'POST',
"/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches",
json_encode($payload)
);
if ($inputsDecoded !== null) {
$payload['inputs'] = $inputsDecoded;
}
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++;
}
$response = $this->apiRequest(
'POST',
"/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches",
json_encode($payload)
);
$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('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
$this->log('INFO', sprintf('%-40s | %s', $repo, $status));
}
return $failCount > 0 ? 1 : 0;
}
$this->log('INFO', '');
$this->log('INFO', 'Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
private function parseArgs(): void
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
return $failCount > 0 ? 1 : 0;
}
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 '--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 buildRepoList(): ?array
{
if ($this->reposFile !== '') {
if (!file_exists($this->reposFile)) {
$this->log('ERROR', "Repos file not found: {$this->reposFile}");
return null;
}
private function printUsage(): void
{
$this->log('Usage: bulk_workflow_trigger.php --token <token> --workflow <file> [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(' --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');
}
$content = file_get_contents($this->reposFile);
$lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool {
return $line !== '' && $line[0] !== '#';
});
private function buildRepoList(): ?array
{
if ($this->reposFile !== '')
{
if (!file_exists($this->reposFile))
{
$this->log("ERROR: Repos file not found: {$this->reposFile}");
return null;
}
return array_values($lines);
}
$content = file_get_contents($this->reposFile);
$lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool {
return $line !== '' && $line[0] !== '#';
});
// Fetch all repos from org
$this->log('INFO', "Fetching repos from org: {$this->org}");
return array_values($lines);
}
$page = 1;
$repos = [];
// Fetch all repos from org
$this->log("Fetching repos from org: {$this->org}");
while (true) {
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
$page = 1;
$repos = [];
if ($response['code'] < 200 || $response['code'] >= 300) {
if ($page === 1) {
$this->log('ERROR', "Could not fetch repos for org (HTTP {$response['code']}).");
return null;
}
while (true)
{
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
break;
}
if ($response['code'] < 200 || $response['code'] >= 300)
{
if ($page === 1)
{
$this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']}).");
return null;
}
$data = json_decode($response['body'], true);
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)
{
break;
}
if ($fullName !== '') {
$repos[] = $fullName;
}
}
foreach ($data as $repo)
{
$fullName = $repo['full_name'] ?? '';
$page++;
}
if ($fullName !== '')
{
$repos[] = $fullName;
}
}
$this->log('INFO', 'Found ' . count($repos) . " repo(s) in org \"{$this->org}\".");
$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
{
$url = $this->giteaUrl . $endpoint;
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$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}",
]);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($body !== null)
{
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
return ['code' => 0, 'body' => "cURL error: {$error}"];
}
if (curl_errno($ch))
{
$error = curl_error($ch);
curl_close($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);
}
return ['code' => $httpCode, 'body' => $responseBody];
}
}
$app = new BulkWorkflowTrigger();
exit($app->run());
$app = new BulkWorkflowTriggerCli();
exit($app->execute());
+77 -66
View File
@@ -1,82 +1,93 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/changelog_promote.php
* 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);
$path = '.';
$version = null;
$date = date('Y-m-d');
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
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 ($arg === '--date' && isset($argv[$i + 1])) $date = $argv[$i + 1];
use MokoCli\CliFramework;
class ChangelogPromoteCli extends CliFramework
{
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) {
fwrite(STDERR, "Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]\n");
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);
$app = new ChangelogPromoteCli();
exit($app->execute());
+107 -106
View File
@@ -1,134 +1,135 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/changelog_prune.php
* 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);
$path = '.';
$keep = 5;
$dryRun = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--keep' && isset($argv[$i + 1])) $keep = (int)$argv[$i + 1];
if ($arg === '--dry-run') $dryRun = true;
if ($arg === '--help') {
echo "changelog_prune — Keep [Unreleased] + last N versioned entries\n\n";
echo "Usage: php changelog_prune.php --path . --keep 5 [--dry-run]\n\n";
echo "Options:\n";
echo " --path Repository path (default: .)\n";
echo " --keep Number of versioned releases to keep (default: 5)\n";
echo " --dry-run Preview without writing\n";
exit(0);
use MokoCli\CliFramework;
class ChangelogPruneCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases');
$this->addArgument('--path', 'Repository path', '.');
$this->addArgument('--keep', 'Number of versioned releases to keep', '5');
}
}
$changelog = realpath($path) . '/CHANGELOG.md';
if (!file_exists($changelog)) {
fwrite(STDERR, "No CHANGELOG.md found at {$path}\n");
exit(1);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$keep = (int) $this->getArgument('--keep');
$content = file_get_contents($changelog);
$lines = explode("\n", $content);
$changelog = realpath($path) . '/CHANGELOG.md';
if (!file_exists($changelog)) {
$this->log('ERROR', "No CHANGELOG.md found at {$path}");
return 1;
}
// Split into sections by ## headings
$sections = [];
$current = [];
$currentHeading = null;
$content = file_get_contents($changelog);
$lines = explode("\n", $content);
foreach ($lines as $line) {
if (preg_match('/^## /', $line)) {
// Split into sections by ## headings
$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) {
$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)
$header = [];
$contentLines = explode("\n", $content);
foreach ($contentLines as $line) {
if (preg_match('/^## /', $line)) {
break;
}
$header[] = $line;
}
// Find the header (everything before the first ## section)
$header = [];
$contentLines = explode("\n", $content);
foreach ($contentLines as $line) {
if (preg_match('/^## /', $line)) {
break;
}
$header[] = $line;
}
// Separate [Unreleased] from versioned sections
$unreleased = null;
$versioned = [];
// Separate [Unreleased] from versioned sections
$unreleased = null;
$versioned = [];
foreach ($sections as $section) {
if (preg_match('/\[Unreleased\]/i', $section['heading'])) {
$unreleased = $section;
} else {
$versioned[] = $section;
foreach ($sections as $section) {
if (preg_match('/\[Unreleased\]/i', $section['heading'])) {
$unreleased = $section;
} else {
$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);
$pruned = $totalVersioned - $keep;
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);
$app = new ChangelogPruneCli();
exit($app->execute());
+38 -81
View File
@@ -8,17 +8,21 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_dashboard.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* BRIEF: Generate unified client dashboard HTML
*/
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 $token = '';
@@ -29,29 +33,47 @@ final class ClientDashboard
private int $sslWarnDays = 30;
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 === '') {
$this->token = getenv('MOKOGITEA_TOKEN') ?: '';
}
if ($this->token === '') {
$this->log('ERROR: --token or MOKOGITEA_TOKEN required.');
$this->printUsage();
$this->log('ERROR', '--token or MOKOGITEA_TOKEN required.');
return 1;
}
$this->log('Gathering client data...');
$this->log('INFO', 'Gathering client data...');
$clients = $this->discoverClients();
if ($clients === null) {
$this->log('ERROR: Could not fetch client repos.');
$this->log('ERROR', 'Could not fetch client repos.');
return 1;
}
$this->log('Found ' . count($clients) . ' client(s).');
$this->log('INFO', 'Found ' . count($clients) . ' client(s).');
foreach ($clients as &$client) {
$this->enrichClient($client);
@@ -63,7 +85,7 @@ final class ClientDashboard
if ($this->outputFile !== '') {
file_put_contents($this->outputFile, $html);
$this->log("Dashboard: {$this->outputFile}");
$this->log('INFO', "Dashboard: {$this->outputFile}");
} else {
fwrite(STDOUT, $html);
}
@@ -151,9 +173,8 @@ final class ClientDashboard
private function enrichClient(array &$client): void
{
$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");
$vars = [];
@@ -185,7 +206,6 @@ final class ClientDashboard
}
}
// SSL
$client['ssl_expiry'] = null;
$client['ssl_days'] = null;
$client['ssl_status'] = 'unknown';
@@ -212,7 +232,6 @@ final class ClientDashboard
}
}
// Last release
$client['last_release'] = '';
$client['last_release_date'] = '';
$relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1");
@@ -461,69 +480,7 @@ CARD;
curl_close($ch);
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();
exit($app->run());
$app = new ClientDashboardCli();
exit($app->execute());
+182 -172
View File
@@ -1,188 +1,198 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_health_check.php
* 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);
$path = '.';
$updateUrl = null;
$siteUrl = null;
$apiToken = null;
$ghOutput = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--update-url' && isset($argv[$i + 1])) $updateUrl = $argv[$i + 1];
if ($arg === '--site-url' && isset($argv[$i + 1])) $siteUrl = $argv[$i + 1];
if ($arg === '--api-token' && isset($argv[$i + 1])) $apiToken = $argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
use MokoCli\CliFramework;
class ClientHealthCheckCli extends CliFramework
{
protected function configure(): void
{
$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;
$checks = [];
// ── 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);
$app = new ClientHealthCheckCli();
exit($app->execute());
+202 -259
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -7,328 +8,270 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_inventory.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* BRIEF: Discover and list all client-waas repos with their server configuration status
*/
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 $token = '';
private bool $jsonOutput = false;
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = '';
private bool $jsonOutput = false;
public function run(): int
{
$this->parseArgs();
protected function configure(): void
{
$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 === '')
{
$this->log('ERROR: --token is required.');
$this->printUsage();
return 1;
}
protected function run(): int
{
$this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$this->token = $this->getArgument('--token');
$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
$orgs = $this->fetchOrgs();
$this->log('INFO', "Scanning Gitea instance: {$this->giteaUrl}");
if ($orgs === null)
{
$this->log('ERROR: Failed to fetch organizations.');
return 1;
}
// Step 1: List all orgs
$orgs = $this->fetchOrgs();
$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
$inventory = [];
$this->log('INFO', 'Found ' . count($orgs) . ' organization(s).');
foreach ($orgs as $org)
{
$orgName = $org['username'] ?? $org['name'] ?? '';
// Step 2 & 3: For each org, find client-waas repos
$inventory = [];
if ($orgName === '')
{
continue;
}
foreach ($orgs as $org) {
$orgName = $org['username'] ?? $org['name'] ?? '';
$repos = $this->fetchOrgRepos($orgName);
if ($orgName === '') {
continue;
}
if ($repos === null)
{
$this->log("WARNING: Could not fetch repos for org: {$orgName}");
continue;
}
$repos = $this->fetchOrgRepos($orgName);
foreach ($repos as $repo)
{
$repoName = $repo['name'] ?? '';
if ($repos === null) {
$this->log('WARNING', "Could not fetch repos for org: {$orgName}");
continue;
}
if (strpos($repoName, 'client-waas') === false)
{
continue;
}
foreach ($repos as $repo) {
$repoName = $repo['name'] ?? '';
$hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']);
$hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']);
if (strpos($repoName, 'client-waas') === false) {
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 = substr($lastPush, 0, 19);
}
$lastPush = $repo['updated_at'] ?? 'unknown';
$status = 'OK';
if ($lastPush !== 'unknown') {
$lastPush = substr($lastPush, 0, 19);
}
if (!$hasDevConfig && !$hasLiveConfig)
{
$status = 'UNCONFIGURED';
}
elseif (!$hasDevConfig)
{
$status = 'NO DEV';
}
elseif (!$hasLiveConfig)
{
$status = 'NO LIVE';
}
$status = 'OK';
$inventory[] = [
'org' => $orgName,
'repo' => $repoName,
'has_dev_config' => $hasDevConfig,
'has_live_config' => $hasLiveConfig,
'last_push' => $lastPush,
'status' => $status,
];
}
}
if (!$hasDevConfig && !$hasLiveConfig) {
$status = 'UNCONFIGURED';
} elseif (!$hasDevConfig) {
$status = 'NO DEV';
} elseif (!$hasLiveConfig) {
$status = 'NO LIVE';
}
// Output results
if ($this->jsonOutput)
{
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return 0;
}
$inventory[] = [
'org' => $orgName,
'repo' => $repoName,
'has_dev_config' => $hasDevConfig,
'has_live_config' => $hasLiveConfig,
'last_push' => $lastPush,
'status' => $status,
];
}
}
if (count($inventory) === 0)
{
$this->log('No client-waas repos found.');
return 0;
}
// Output results
if ($this->jsonOutput) {
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return 0;
}
// Print table
$this->log('');
$this->log(sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status'
));
$this->log(str_repeat('-', 120));
if (count($inventory) === 0) {
$this->log('INFO', 'No client-waas repos found.');
return 0;
}
foreach ($inventory as $entry)
{
$this->log(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']
));
}
// Print table
$this->log('INFO', '');
$this->log('INFO', sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
'Org',
'Repo',
'Dev Config',
'Live Config',
'Last Push',
'Status'
));
$this->log('INFO', str_repeat('-', 120));
$this->log('');
$this->log('Total: ' . count($inventory) . ' client-waas repo(s).');
foreach ($inventory as $entry) {
$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
{
$args = $_SERVER['argv'] ?? [];
$count = count($args);
return 0;
}
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 '--json':
$this->jsonOutput = true;
break;
case '--help':
case '-h':
$this->printUsage();
exit(0);
default:
$this->log("WARNING: Unknown argument: {$args[$i]}");
break;
}
}
}
private function fetchOrgs(): ?array
{
// Try admin endpoint first, fall back to user-visible orgs
$response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50');
private function printUsage(): void
{
$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');
}
if ($response['code'] >= 200 && $response['code'] < 300) {
$data = json_decode($response['body'], true);
private function fetchOrgs(): ?array
{
// Try admin endpoint first, fall back to user-visible orgs
$response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50');
if (is_array($data)) {
return $data;
}
}
if ($response['code'] >= 200 && $response['code'] < 300)
{
$data = json_decode($response['body'], true);
$this->log('INFO', 'Admin orgs endpoint unavailable, falling back to user orgs...');
if (is_array($data))
{
return $data;
}
}
$response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50');
$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)
{
$data = json_decode($response['body'], true);
return null;
}
if (is_array($data))
{
return $data;
}
}
private function fetchOrgRepos(string $org): ?array
{
$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
{
$page = 1;
$allRepos = [];
if ($response['code'] < 200 || $response['code'] >= 300) {
return $page === 1 ? null : $allRepos;
}
while (true)
{
$response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}");
$data = json_decode($response['body'], true);
if ($response['code'] < 200 || $response['code'] >= 300)
{
return $page === 1 ? null : $allRepos;
}
if (!is_array($data) || count($data) === 0) {
break;
}
$data = json_decode($response['body'], true);
$allRepos = array_merge($allRepos, $data);
$page++;
}
if (!is_array($data) || count($data) === 0)
{
break;
}
return $allRepos;
}
$allRepos = array_merge($allRepos, $data);
$page++;
}
private function checkVariables(string $org, string $repo, array $requiredVars): bool
{
$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
{
$response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables");
$data = json_decode($response['body'], true);
if ($response['code'] < 200 || $response['code'] >= 300)
{
return false;
}
if (!is_array($data)) {
return false;
}
$data = json_decode($response['body'], true);
$existingVars = [];
if (!is_array($data))
{
return false;
}
foreach ($data as $variable) {
if (isset($variable['name'])) {
$existingVars[] = $variable['name'];
}
}
$existingVars = [];
foreach ($requiredVars as $var) {
if (!in_array($var, $existingVars, true)) {
return false;
}
}
foreach ($data as $variable)
{
if (isset($variable['name']))
{
$existingVars[] = $variable['name'];
}
}
return true;
}
foreach ($requiredVars as $var)
{
if (!in_array($var, $existingVars, true))
{
return false;
}
}
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
{
$url = $this->giteaUrl . $endpoint;
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
{
$url = $this->giteaUrl . $endpoint;
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$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}",
]);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($body !== null)
{
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
return ['code' => 0, 'body' => "cURL error: {$error}"];
}
if (curl_errno($ch))
{
$error = curl_error($ch);
curl_close($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);
}
return ['code' => $httpCode, 'body' => $responseBody];
}
}
$app = new ClientInventory();
exit($app->run());
$app = new ClientInventoryCli();
exit($app->execute());
+71 -114
View File
@@ -8,17 +8,21 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_provision.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* BRIEF: Provision a new client environment end-to-end
*/
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 $giteaToken = '';
@@ -26,24 +30,30 @@ final class ClientProvision
private string $grafanaToken = '';
private string $configFile = '';
private string $step = '';
private bool $dryRun = false;
/** @var array<string, mixed> */
private array $config = [];
private string $org = '';
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 === '') {
$this->log('ERROR: --config is required.');
$this->printUsage();
$this->log('ERROR', '--config is required.');
return 1;
}
if (!file_exists($this->configFile)) {
$this->log("ERROR: Not found: {$this->configFile}");
$this->log('ERROR', "Not found: {$this->configFile}");
return 1;
}
@@ -51,7 +61,7 @@ final class ClientProvision
$this->config = json_decode($json, true);
if (!is_array($this->config)) {
$this->log('ERROR: Invalid JSON in config file.');
$this->log('ERROR', 'Invalid JSON in config file.');
return 1;
}
@@ -65,7 +75,7 @@ final class ClientProvision
?? $this->giteaUrl;
if ($this->giteaToken === '') {
$this->log('ERROR: gitea_token or MOKOGITEA_TOKEN required.');
$this->log('ERROR', 'gitea_token or MOKOGITEA_TOKEN required.');
return 1;
}
@@ -73,21 +83,21 @@ final class ClientProvision
$clientName = $this->config['name'] ?? '';
if ($this->org === '' || $clientName === '') {
$this->log('ERROR: "org" and "name" required in config.');
$this->log('ERROR', '"org" and "name" required in config.');
return 1;
}
$this->repoName = 'client-waas-' . $clientName;
$this->log("=== Client Provisioning: {$clientName} ===");
$this->log(" Org: {$this->org}");
$this->log(" Repo: {$this->repoName}");
$this->log('INFO', "=== Client Provisioning: {$clientName} ===");
$this->log('INFO', " Org: {$this->org}");
$this->log('INFO', " Repo: {$this->repoName}");
if ($this->dryRun) {
$this->log(' Mode: DRY RUN');
$this->log('INFO', ' Mode: DRY RUN');
}
$this->log('');
echo "\n";
$steps = [
'repo' => 'createRepo',
@@ -116,7 +126,7 @@ final class ClientProvision
private function createRepo(): int
{
$this->log('[1/5] Creating repository...');
$this->log('INFO', '[1/5] Creating repository...');
$check = $this->giteaApi(
'GET',
@@ -124,14 +134,12 @@ final class ClientProvision
);
if ($check['code'] === 200) {
$this->log(" SKIP: repo already exists");
$this->log('INFO', ' SKIP: repo already exists');
return 0;
}
if ($this->dryRun) {
$this->log(
" WOULD CREATE: {$this->org}/{$this->repoName}"
);
$this->log('INFO', " WOULD CREATE: {$this->org}/{$this->repoName}");
return 0;
}
@@ -153,11 +161,11 @@ final class ClientProvision
);
if ($resp['code'] < 200 || $resp['code'] >= 300) {
$this->log(" ERROR: HTTP {$resp['code']}");
$this->log('ERROR', "HTTP {$resp['code']}");
return 1;
}
$this->log(' OK: Repo created');
$this->log('INFO', ' OK: Repo created');
$this->giteaApi(
'POST',
@@ -168,19 +176,19 @@ final class ClientProvision
])
);
$this->log(' OK: dev branch created');
$this->log('INFO', ' OK: dev branch created');
return 0;
}
private function setVariables(): int
{
$this->log('[2/5] Setting repo variables...');
$this->log('INFO', '[2/5] Setting repo variables...');
$vars = $this->config['variables'] ?? [];
if (empty($vars)) {
$this->log(' SKIP: No variables in config');
$this->log('INFO', ' SKIP: No variables in config');
return 0;
}
@@ -192,16 +200,16 @@ final class ClientProvision
if ($this->dryRun) {
$display = strlen($value) > 40
? substr($value, 0, 37) . '...' : $value;
$this->log(" WOULD SET: {$name} = {$display}");
$this->log('INFO', " WOULD SET: {$name} = {$display}");
continue;
}
$ok = $this->setOrCreateVariable($api, $name, $value);
if ($ok) {
$this->log(" OK: {$name}");
$this->log('INFO', " OK: {$name}");
} else {
$this->log(" ERROR: {$name}");
$this->log('ERROR', " {$name}");
$errors++;
}
}
@@ -211,12 +219,12 @@ final class ClientProvision
private function setSecrets(): int
{
$this->log('[3/5] Setting repo secrets...');
$this->log('INFO', '[3/5] Setting repo secrets...');
$secrets = $this->config['secrets'] ?? [];
if (empty($secrets)) {
$this->log(' SKIP: No secrets in config');
$this->log('INFO', ' SKIP: No secrets in config');
return 0;
}
@@ -229,7 +237,7 @@ final class ClientProvision
$keyPath = substr($value, 1);
if (!file_exists($keyPath)) {
$this->log(" ERROR: {$name} file not found: {$keyPath}");
$this->log('ERROR', " {$name} file not found: {$keyPath}");
$errors++;
continue;
}
@@ -238,7 +246,7 @@ final class ClientProvision
}
if ($this->dryRun) {
$this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")");
$this->log('INFO', " WOULD SET: {$name} (len: " . strlen($value) . ")");
continue;
}
@@ -249,9 +257,9 @@ final class ClientProvision
);
if ($resp['code'] >= 200 && $resp['code'] < 300) {
$this->log(" OK: {$name}");
$this->log('INFO', " OK: {$name}");
} else {
$this->log(" ERROR: {$name} (HTTP {$resp['code']})");
$this->log('ERROR', " {$name} (HTTP {$resp['code']})");
$errors++;
}
}
@@ -261,12 +269,12 @@ final class ClientProvision
private function setupMonitoring(): int
{
$this->log('[4/5] Setting up monitoring...');
$this->log('INFO', '[4/5] Setting up monitoring...');
$mon = $this->config['monitoring'] ?? [];
if (empty($mon)) {
$this->log(' SKIP: No monitoring config');
$this->log('INFO', ' SKIP: No monitoring config');
return 0;
}
@@ -291,10 +299,10 @@ final class ClientProvision
$urlStr = implode("\n", $urls);
if ($this->dryRun) {
$this->log(" WOULD SET: MONITORED_URLS");
$this->log('INFO', ' WOULD SET: MONITORED_URLS');
} else {
$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);
if ($this->dryRun) {
$this->log(" WOULD SET: MONITORED_DOMAINS");
$this->log('INFO', ' WOULD SET: MONITORED_DOMAINS');
} else {
$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
{
if (!file_exists($file)) {
$this->log(" WARN: Dashboard not found: {$file}");
$this->warning("Dashboard not found: {$file}");
return;
}
if ($this->dryRun) {
$this->log(" WOULD PUSH: dashboard to \"{$folder}\"");
$this->log('INFO', " WOULD PUSH: dashboard to \"{$folder}\"");
return;
}
$dashboard = json_decode(file_get_contents($file), true);
if (!is_array($dashboard)) {
$this->log(' ERROR: Invalid dashboard JSON');
$this->log('ERROR', 'Invalid dashboard JSON');
return;
}
@@ -346,9 +354,9 @@ final class ClientProvision
if ($resp['code'] === 200) {
$data = json_decode($resp['body'], true);
$this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
$this->log('INFO', " OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
} 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'] ?? [];
$secrets = $this->config['secrets'] ?? [];
$clientName = $this->config['name'] ?? '';
$this->log('');
$this->log('[5/5] Provisioning summary');
$this->log(str_repeat('=', 60));
$this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}");
$this->log(' Variables: ' . count($vars) . ' set');
$this->log(' Secrets: ' . count($secrets) . ' set');
$this->log('');
$this->log('Next steps:');
$this->log(' 1. Clone and customize the Joomla template');
$this->log(' 2. Push to dev to trigger dev deployment');
$this->log(' 3. Merge dev -> main for production release');
$this->log(str_repeat('=', 60));
echo "\n";
$this->log('INFO', '[5/5] Provisioning summary');
echo str_repeat('=', 60) . "\n";
echo " Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}\n";
echo ' Variables: ' . count($vars) . " set\n";
echo ' Secrets: ' . count($secrets) . " set\n";
echo "\n";
echo "Next steps:\n";
echo " 1. Clone and customize the Joomla template\n";
echo " 2. Push to dev to trigger dev deployment\n";
echo " 3. Merge dev -> main for production release\n";
echo str_repeat('=', 60) . "\n";
return 0;
}
@@ -419,51 +426,6 @@ final class ClientProvision
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(
string $method,
string $endpoint,
@@ -523,12 +485,7 @@ final class ClientProvision
return ['code' => $httpCode, 'body' => $responseBody];
}
private function log(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
}
$app = new ClientProvision();
exit($app->run());
$app = new ClientProvisionCli();
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
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -7,474 +8,436 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/create_project.php
* 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);
$dryRun = in_array('--dry-run', $argv);
$allMode = in_array('--all', $argv);
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$org = 'mokoconsulting-tech';
$repoName = null;
$typeOverride = null;
use MokoCli\CliFramework;
foreach ($argv as $i => $arg) {
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
class CreateProjectCli extends CliFramework
{
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: MokoStandards-CreateProject',
],
]);
$body = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
/** @var string[] */
private array $ALWAYS_EXCLUDE = ['mokocli', '.github-private'];
if ($status !== 200) {
fwrite(STDERR, "GraphQL request failed (HTTP {$status}): {$body}\n");
return [];
}
/** @var array<string, string> */
private array $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',
];
$data = json_decode($body, true) ?? [];
if (!empty($data['errors'])) {
foreach ($data['errors'] as $err) {
fwrite(STDERR, " GraphQL error: " . ($err['message'] ?? 'unknown') . "\n");
}
}
/** @var array<string, string> */
private array $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',
];
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;
}
}
/**
* Execute a REST API GET call via the platform adapter's ApiClient.
*
* @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);
$app = new CreateProjectCli();
exit($app->execute());
+206 -232
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -7,247 +8,220 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/create_repo.php
* BRIEF: Scaffold a new governed repository with full MokoStandards 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
* BRIEF: Scaffold a new governed repository with full mokocli baseline
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\Config;
use MokoEnterprise\PlatformAdapterFactory;
use MokoCli\CliFramework;
use MokoCli\Config;
use MokoCli\PlatformAdapterFactory;
$dryRun = in_array('--dry-run', $argv);
$private = in_array('--private', $argv);
class CreateRepoCli extends CliFramework
{
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;
$type = null;
$description = '';
protected function run(): int
{
$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) {
if ($arg === '--name' && isset($argv[$i + 1])) { $name = $argv[$i + 1]; }
if ($arg === '--type' && isset($argv[$i + 1])) { $type = $argv[$i + 1]; }
if ($arg === '--description' && isset($argv[$i + 1])) { $description = $argv[$i + 1]; }
echo "Step 1: Creating repository...\n";
if (!$this->dryRun) {
try {
$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) {
fwrite(STDERR, "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]\n");
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";
$app = new CreateRepoCli();
exit($app->execute());
File diff suppressed because it is too large Load Diff
+84 -81
View File
@@ -1,97 +1,100 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/dev_branch_reset.php
* 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);
$token = null;
$apiBase = null;
$branch = 'dev';
$from = 'main';
$outputSummary = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
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 === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1];
if ($arg === '--output-summary') $outputSummary = true;
use MokoCli\CliFramework;
class DevBranchResetCli extends CliFramework
{
protected function configure(): void
{
$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;
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);
$app = new DevBranchResetCli();
exit($app->execute());
+81 -178
View File
@@ -8,17 +8,21 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/grafana_dashboard.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* BRIEF: Manage Grafana dashboards via API
*/
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 $token = '';
@@ -29,24 +33,52 @@ final class GrafanaDashboard
private string $folderTitle = '';
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 === '') {
$this->grafanaUrl = getenv('GRAFANA_URL') ?: '';
}
$this->grafanaUrl = rtrim($this->grafanaUrl, '/');
if ($this->token === '') {
$this->token = getenv('GRAFANA_TOKEN') ?: '';
}
if ($this->grafanaUrl === '' || $this->token === '') {
$this->log(
'ERROR: --url and --token are required '
. '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).'
);
$this->printUsage();
$this->log('ERROR', '--url and --token are required (or set GRAFANA_URL / GRAFANA_TOKEN env vars).');
return 1;
}
@@ -62,12 +94,12 @@ final class GrafanaDashboard
private function pushDashboard(): int
{
if ($this->file === '') {
$this->log('ERROR: --file is required for push.');
$this->log('ERROR', '--file is required for push.');
return 1;
}
if (!file_exists($this->file)) {
$this->log("ERROR: File not found: {$this->file}");
$this->log('ERROR', "File not found: {$this->file}");
return 1;
}
@@ -75,14 +107,12 @@ final class GrafanaDashboard
$dashboard = json_decode($json, true);
if (!is_array($dashboard)) {
$this->log('ERROR: Invalid JSON in dashboard file.');
$this->log('ERROR', 'Invalid JSON in dashboard file.');
return 1;
}
if ($this->folderTitle !== '' && $this->folderId === 0) {
$this->folderId = $this->resolveFolderId(
$this->folderTitle
);
$this->folderId = $this->resolveFolderId($this->folderTitle);
if ($this->folderId < 0) {
return 1;
@@ -97,29 +127,23 @@ final class GrafanaDashboard
'overwrite' => $this->overwrite,
]);
$response = $this->apiRequest(
'POST',
'/api/dashboards/db',
$payload
);
$response = $this->apiRequest('POST', '/api/dashboards/db', $payload);
if ($response['code'] === 200) {
$data = json_decode($response['body'], true);
$uid = $data['uid'] ?? '?';
$url = $data['url'] ?? '';
$status = $data['status'] ?? 'success';
$this->log("OK: {$status} (uid: {$uid})");
$this->log('INFO', "OK: {$status} (uid: {$uid})");
if ($url !== '') {
$this->log("URL: {$this->grafanaUrl}{$url}");
$this->log('INFO', "URL: {$this->grafanaUrl}{$url}");
}
return 0;
}
$this->log(
"ERROR: Push failed (HTTP {$response['code']})"
);
$this->log('ERROR', "Push failed (HTTP {$response['code']})");
$this->logApiError($response['body']);
return 1;
@@ -128,30 +152,23 @@ final class GrafanaDashboard
private function deleteDashboard(): int
{
if ($this->uid === '') {
$this->log('ERROR: --uid is required for delete.');
$this->log('ERROR', '--uid is required for delete.');
return 1;
}
$response = $this->apiRequest(
'DELETE',
"/api/dashboards/uid/{$this->uid}"
);
$response = $this->apiRequest('DELETE', "/api/dashboards/uid/{$this->uid}");
if ($response['code'] === 200) {
$this->log("OK: Deleted dashboard {$this->uid}");
$this->log('INFO', "OK: Deleted dashboard {$this->uid}");
return 0;
}
if ($response['code'] === 404) {
$this->log(
"WARN: Dashboard {$this->uid} not found."
);
$this->warning("Dashboard {$this->uid} not found.");
return 0;
}
$this->log(
"ERROR: Delete failed (HTTP {$response['code']})"
);
$this->log('ERROR', "Delete failed (HTTP {$response['code']})");
$this->logApiError($response['body']);
return 1;
@@ -176,42 +193,33 @@ final class GrafanaDashboard
$response = $this->apiRequest('GET', $query);
if ($response['code'] !== 200) {
$this->log(
"ERROR: List failed (HTTP {$response['code']})"
);
$this->log('ERROR', "List failed (HTTP {$response['code']})");
$this->logApiError($response['body']);
return 1;
}
$dashboards = json_decode($response['body'], true);
if (
!is_array($dashboards)
|| count($dashboards) === 0
) {
$this->log('No dashboards found.');
if (!is_array($dashboards) || count($dashboards) === 0) {
$this->log('INFO', 'No dashboards found.');
return 0;
}
$this->log(sprintf(
'%-30s | %-20s | %s',
'Title',
'UID',
'Folder'
));
$this->log(str_repeat('-', 75));
fprintf(STDERR, "%-30s | %-20s | %s\n", 'Title', 'UID', 'Folder');
fprintf(STDERR, "%s\n", str_repeat('-', 75));
foreach ($dashboards as $d) {
$this->log(sprintf(
'%-30s | %-20s | %s',
fprintf(
STDERR,
"%-30s | %-20s | %s\n",
substr($d['title'] ?? '', 0, 30),
$d['uid'] ?? '',
$d['folderTitle'] ?? 'General'
));
);
}
$this->log('');
$this->log(count($dashboards) . ' dashboard(s).');
echo "\n";
$this->log('INFO', count($dashboards) . ' dashboard(s).');
return 0;
}
@@ -219,20 +227,14 @@ final class GrafanaDashboard
private function exportDashboard(): int
{
if ($this->uid === '') {
$this->log('ERROR: --uid is required for export.');
$this->log('ERROR', '--uid is required for export.');
return 1;
}
$response = $this->apiRequest(
'GET',
"/api/dashboards/uid/{$this->uid}"
);
$response = $this->apiRequest('GET', "/api/dashboards/uid/{$this->uid}");
if ($response['code'] !== 200) {
$this->log(
"ERROR: Export failed "
. "(HTTP {$response['code']})"
);
$this->log('ERROR', "Export failed (HTTP {$response['code']})");
$this->logApiError($response['body']);
return 1;
}
@@ -241,9 +243,7 @@ final class GrafanaDashboard
$dashboard = $data['dashboard'] ?? null;
if ($dashboard === null) {
$this->log(
'ERROR: No dashboard data in response.'
);
$this->log('ERROR', 'No dashboard data in response.');
return 1;
}
@@ -254,9 +254,7 @@ final class GrafanaDashboard
if ($this->file !== '') {
file_put_contents($this->file, $output);
$this->log(
"Exported {$this->uid} to {$this->file}"
);
$this->log('INFO', "Exported {$this->uid} to {$this->file}");
} else {
fwrite(STDOUT, $output);
}
@@ -269,10 +267,7 @@ final class GrafanaDashboard
$response = $this->apiRequest('GET', '/api/folders');
if ($response['code'] !== 200) {
$this->log(
"ERROR: Could not fetch folders "
. "(HTTP {$response['code']})"
);
$this->log('ERROR', "Could not fetch folders (HTTP {$response['code']})");
return -1;
}
@@ -283,106 +278,22 @@ final class GrafanaDashboard
}
foreach ($folders as $f) {
if (
strcasecmp(
$f['title'] ?? '',
$title
) === 0
) {
if (strcasecmp($f['title'] ?? '', $title) === 0) {
return (int) ($f['id'] ?? 0);
}
}
$this->log(
"WARN: Folder \"{$title}\" not found, "
. "using General."
);
$this->warning("Folder \"{$title}\" not found, using General.");
return 0;
}
private function noCommand(): int
{
$this->log('ERROR: No command specified.');
$this->printUsage();
$this->log('ERROR', 'No command specified. Use: push, delete, list, export');
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(
string $method,
string $endpoint,
@@ -405,10 +316,7 @@ final class GrafanaDashboard
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo(
$ch,
CURLINFO_HTTP_CODE
);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$error = curl_error($ch);
@@ -430,15 +338,10 @@ final class GrafanaDashboard
$data = json_decode($body, true);
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();
exit($app->run());
$app = new GrafanaDashboardCli();
exit($app->execute());
+330 -288
View File
@@ -1,307 +1,349 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_build.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
* 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);
// ── Argument parsing ────────────────────────────────────────────────────
$path = '.';
$version = '';
$suffix = '';
$outputDir = 'build';
$ghOutput = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
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 ($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;
}
use MokoCli\{CliFramework, SourceResolver};
if ($version === '') {
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
class JoomlaBuildCli extends CliFramework
{
// 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;
protected function configure(): void
{
$this->setDescription('Build a Joomla extension ZIP from manifest');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--suffix', 'Version suffix (e.g. -dev)', '');
$this->addArgument('--output', 'Output directory', 'build');
$this->addArgument('--github-output', 'Write outputs to GITHUB_OUTPUT file', false);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$suffix = $this->getArgument('--suffix');
$outputDir = $this->getArgument('--output');
$ghOutput = (bool) $this->getArgument('--github-output');
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
{
$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);
}
$app = new JoomlaBuildCli();
exit($app->execute());
+128 -120
View File
@@ -1,136 +1,144 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_compat_check.php
* 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);
$path = '.';
$ghOutput = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
use MokoCli\CliFramework;
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;
// ── 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);
$app = new JoomlaCompatCheckCli();
exit($app->execute());
+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.05
* 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
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -7,9 +8,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_release.php
* 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';
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
use MokoCli\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory, SourceResolver};
/**
* Joomla Release Manager
@@ -36,7 +37,7 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapte
*/
class JoomlaRelease extends CliFramework
{
private const VERSION = '04.06.00';
private const VERSION = '09.23.00';
private const ORG = 'MokoConsulting';
private const STABILITY_TAGS = [
@@ -55,17 +56,17 @@ class JoomlaRelease extends CliFramework
'stable' => '',
];
private ApiClient $api;
private \MokoEnterprise\GitPlatformAdapter $adapter;
private ApiClient $api;
private \MokoCli\GitPlatformAdapter $adapter;
protected function configure(): void
{
$this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml');
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
$this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable');
$this->addArgument('--dry-run', 'Preview without making changes', false);
$this->addArgument('--verbose', 'Show detailed output', false);
$this->addArgument('--dry-run', 'Preview without making changes', false);
$this->addArgument('--verbose', 'Show detailed output', false);
}
protected function run(): int
@@ -86,7 +87,9 @@ class JoomlaRelease extends CliFramework
if ($repo !== '') {
$path = $this->cloneRepo($repo);
if ($path === null) { return 1; }
if ($path === null) {
return 1;
}
}
$path = rtrim($path, '/\\');
@@ -118,11 +121,12 @@ class JoomlaRelease extends CliFramework
$this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}");
// ── 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) {
$this->log('ERROR', 'No src/ or htdocs/ directory');
$this->log('ERROR', 'No source/ or src/ directory');
return 1;
}
SourceResolver::warnIfLegacy($path);
$prefix = $this->typePrefix($meta);
$zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip";
@@ -191,7 +195,9 @@ class JoomlaRelease extends CliFramework
private function findManifest(string $path): ?string
{
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
if (!is_dir($dir)) { continue; }
if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") as $file) {
if (str_contains((string) file_get_contents($file), '<extension')) {
return $file;
@@ -235,7 +241,9 @@ class JoomlaRelease extends CliFramework
private function readVersion(string $path): ?string
{
$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)) {
return $m[1];
}
@@ -301,8 +309,12 @@ class JoomlaRelease extends CliFramework
}
// 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 (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->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
@@ -321,7 +333,9 @@ class JoomlaRelease extends CliFramework
*/
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(
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
@@ -342,7 +356,9 @@ class JoomlaRelease extends CliFramework
);
foreach ($iter as $file) {
$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);
}
$zip->close();
@@ -359,17 +375,29 @@ class JoomlaRelease extends CliFramework
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 ($name === '.ftpignore') {
return true;
}
if (str_starts_with($name, 'sftp-config')) {
return true;
}
if (str_starts_with($name, '.env')) {
return true;
}
$ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key'], true);
}
// ── 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 !== ''
? "{$extName} {$version} ({$packageName})"
: (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})");
@@ -379,7 +407,7 @@ class JoomlaRelease extends CliFramework
$this->api->post("/repos/{$repo}/releases", [
'tag_name' => $tag,
'name' => $releaseName,
'body' => "## {$version}\n\nCreated by MokoStandards release pipeline.",
'body' => "## {$version}\n\nCreated by mokocli release pipeline.",
'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.05
* 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
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_element.php
* 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);
$path = '.';
$version = null;
$stability = 'stable';
$githubOutput = false;
$repoName = '';
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, SourceResolver};
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;
// ── Detect platform from manifest.xml ────────────────────────────────────────
$platform = 'generic';
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml);
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$platform = trim($pm[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 (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)));
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$stability = $this->getArgument('--stability');
$repoName = $this->getArgument('--repo');
$githubOutput = (bool) $this->getArgument('--github-output');
$root = realpath($path) ?: $path;
$platform = 'generic';
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml);
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$platform = trim($pm[1]);
}
}
// Human-readable name
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
$extName = trim($nm[1]);
$extManifest = null;
$manifestFiles = array_merge(SourceResolver::globSource($root, 'pkg_*.xml'), SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: []);
foreach ($manifestFiles as $file) {
$c = file_get_contents($file);
if (strpos($c, '<extension') !== false) {
$extManifest = $file;
break;
}
}
break;
// Dolibarr platforms
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
$extType = 'dolibarr-module';
$modBasename = basename($modFile, '.class.php');
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
$modContent = file_get_contents($modFile);
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
$extName = $nm[1];
$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 (strpos($c, 'extends DolibarrModules') !== false) {
$modFile = $file;
break;
}
}
break;
// Generic / fallback
default:
$extElement = strtolower(str_replace([' ', '-'], '', $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);
// ── Compute type prefix ──────────────────────────────────────────────────────
$typePrefix = '';
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;
}
// ── Compute ZIP name ─────────────────────────────────────────────────────────
$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";
}
// 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 = '';
$extType = '';
$extFolder = '';
$extName = '';
switch (true) {
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest);
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('/module="([^"]*)"/', $xml, $mm)) {
$extElement = $mm[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 ?: 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));
$modContent = file_get_contents($modFile);
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
$extName = $nm[1];
}
break;
default:
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
$extType = 'generic';
break;
}
}
} else {
foreach ($outputs as $key => $value) {
echo "{$key}={$value}\n";
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
$typePrefix = '';
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;
}
$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.05
* 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.05
* 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
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_read.php
* VERSION: 09.21.00
* BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption
*
* 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)
* VERSION: 09.38.05
* BRIEF: Read repo metadata from Gitea manifest API, auto-detect the rest
*/
declare(strict_types=1);
// -- Argument parsing ---------------------------------------------------------
$path = '.';
$field = null;
$mode = 'field'; // field | all | github-output | json
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
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';
}
use MokoCli\CliFramework;
// -- Locate manifest ----------------------------------------------------------
$root = realpath($path) ?: $path;
$manifestFile = null;
class ManifestReadCli extends CliFramework
{
/** Joomla extension XML element names searched in root and source/ dirs. */
private const JOOMLA_XML_ROOTS = ['extension', 'install'];
// Priority: manifest.xml (current standard)
$candidates = [
"{$root}/.mokogitea/manifest.xml",
"{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed)
"{$root}/.mokogitea/.moko-platform", // legacy v4
];
foreach ($candidates as $candidate) {
if (file_exists($candidate)) {
$manifestFile = $candidate;
break;
protected function configure(): void
{
$this->setDescription('Read repo metadata from Gitea API with auto-detection fallback');
$this->addArgument('--path', 'Repository root path', '.');
$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);
$this->addArgument('--json', 'Output all fields as JSON', false);
}
}
if ($manifestFile === null) {
fwrite(STDERR, "No manifest found in {$root}
");
exit(1);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$field = $this->getArgument('--field');
$showAll = $this->getArgument('--all');
$ghOut = $this->getArgument('--github-output');
$jsonMode = $this->getArgument('--json');
// -- Parse XML ----------------------------------------------------------------
$xml = @simplexml_load_file($manifestFile);
$mode = match (true) {
(bool) $ghOut => 'github-output',
(bool) $showAll => 'all',
(bool) $jsonMode => 'json',
default => 'field',
};
if ($xml === false) {
// 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], "
$root = realpath($path) ?: $path;
\"'");
}
if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) {
$fields['standards-version'] = trim($m[1], "
// ── 1. Resolve org/repo ──────────────────────────────────────────
[$org, $repo] = $this->resolveOrgRepo($root);
\"'");
}
if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) {
$fields['name'] = trim($m[1], "
\"'");
}
} 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);
// ── 2. Primary: Gitea manifest API ───────────────────────────────
$fields = null;
if ($org !== '' && $repo !== '') {
$fields = $this->fetchFromApi($org, $repo);
}
echo ($fields[$field] ?? '') . "
";
break;
case 'all':
foreach ($fields as $k => $v) {
echo "{$k}={$v}
";
// ── 3. Fallback: auto-detect from source tree ────────────────────
if ($fields === null) {
$this->log('INFO', 'API unavailable — falling back to source-tree detection');
$fields = $this->autoDetect($root, $repo);
}
break;
case 'json':
echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "
";
break;
if (empty($fields)) {
$this->log('ERROR', "Could not resolve metadata for {$root}");
return 1;
}
case 'github-output':
$outputFile = getenv('GITHUB_OUTPUT');
if ($outputFile === false || $outputFile === '') {
fwrite(STDERR, "GITHUB_OUTPUT not set — printing to stdout instead
");
foreach ($fields as $k => $v) {
// Convert field-name to FIELD_NAME for env var style
$envKey = str_replace('-', '_', $k);
echo "{$envKey}={$v}
";
// Provide backward-compatible aliases (hyphenated → underscore)
$fields = $this->addAliases($fields);
// Strip empty values
$fields = array_filter($fields, fn($v) => $v !== '' && $v !== null);
// ── 4. Output ────────────────────────────────────────────────────
return $this->outputFields($fields, $mode, $field);
}
// ── 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
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/package_build.php
* 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.
*/
declare(strict_types=1);
$path = '.';
$version = null;
$outputDir = '/tmp';
$typePrefixOverride = null;
$elementOverride = null;
$githubOutput = false;
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, SourceResolver};
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) {
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n");
exit(1);
}
protected function run(): int
{
$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;
// Ensure output directory exists
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 ($version === '') {
$this->log('ERROR', 'Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]');
return 1;
}
}
if ($manifest === null) {
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
$root = realpath($path) ?: $path;
// Ensure output directory exists
if (!is_dir($outputDir)) {
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 (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));
$extElement = strtolower(basename($root));
}
// Prevent double prefix (e.g. pkg_pkg_mokogallery)
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
}
$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)) {
$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;
// Copy package-level files (manifest, script.php, etc.)
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
copy($f, "{$stagingDir}/" . basename($f));
}
}
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
}
}
if ($extElement === null) {
$extElement = strtolower(basename($root));
}
// Prevent double prefix (e.g. pkg_pkg_mokogallery)
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
}
$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) {
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);
// 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
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
exit(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;
// Create ZIP from staging
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
$this->log('ERROR', "Failed to create ZIP: {$zipPath}");
return 1;
}
}
if ($skip) {
continue;
}
$this->addDirectoryToZip($zip, $stagingDir, '', []);
$zip->close();
// Normalize path separators for ZIP
$relativePath = str_replace('\\', '/', $relativePath);
// Create tar.gz — all arguments are escaped via escapeshellarg()
$tarCmd = sprintf(
'tar -czf %s -C %s .',
escapeshellarg($tarPath),
escapeshellarg($stagingDir)
);
passthru($tarCmd, $tarReturn);
if ($file->isDir()) {
$zip->addEmptyDir($relativePath);
// Cleanup staging
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
passthru($cleanCmd);
} 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
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/platform_detect.php
* BRIEF: Detect platform from .mokostandards file — outputs platform string
* VERSION: 09.38.05
* BRIEF: Auto-detect repository platform type and optionally update manifest
*/
declare(strict_types=1);
$path = '.';
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
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;
// Check .github/.mokostandards first, fallback to root
$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);
$app = new PlatformDetectCli();
exit($app->execute());
+174 -153
View File
@@ -1,171 +1,192 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release.php
* BRIEF: Automate the MokoStandards 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
* BRIEF: Automate the mokocli version branch release flow
*/
declare(strict_types=1);
$dryRun = in_array('--dry-run', $argv);
$bumpType = null;
foreach ($argv as $i => $arg) {
if ($arg === '--bump' && isset($argv[$i + 1])) {
$bumpType = $argv[$i + 1]; // patch | minor | major
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
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);
$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)) {
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");
protected function run(): int
{
$bumpType = $this->getArgument('--bump');
if (empty($bumpType)) {
$bumpType = null;
}
$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) ───────────────────
$tag = "v{$currentVersion}";
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";
$app = new ReleaseCli();
exit($app->execute());
+142 -136
View File
@@ -1,152 +1,158 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_body_update.php
* 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);
$path = '.';
$version = null;
$releaseTag = null;
$token = null;
$apiBase = null;
$zipName = null;
$tarName = null;
$zipSha = null;
$tarSha = null;
$outputSummary = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
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 ($arg === '--release-tag' && isset($argv[$i + 1])) $releaseTag = $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 === '--zip-name' && isset($argv[$i + 1])) $zipName = $argv[$i + 1];
if ($arg === '--tar-name' && isset($argv[$i + 1])) $tarName = $argv[$i + 1];
if ($arg === '--zip-sha' && isset($argv[$i + 1])) $zipSha = $argv[$i + 1];
if ($arg === '--tar-sha' && isset($argv[$i + 1])) $tarSha = $argv[$i + 1];
if ($arg === '--output-summary') $outputSummary = true;
use MokoCli\CliFramework;
class ReleaseBodyUpdateCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Update Gitea release body with changelog extract and checksums');
$this->addArgument('--path', 'Repo root for CHANGELOG.md', '.');
$this->addArgument('--version', 'Version string', '');
$this->addArgument('--release-tag', 'Gitea release tag', '');
$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;
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);
$app = new ReleaseBodyUpdateCli();
exit($app->execute());
+333 -7
View File
@@ -1,17 +1,343 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_cascade.php
* VERSION: 09.21.00
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent.
* VERSION: 09.38.05
* BRIEF: Cascade release zip to all lower stability channels
*/
echo "release_cascade.php: No-op (cascade behavior removed — each stream is independent)\n";
exit(0);
declare(strict_types=1);
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
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_create.php
* 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);
// ── Argument parsing ────────────────────────────────────────────────────────
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$path = '.';
$version = null;
$tag = null;
$token = null;
$apiBase = null;
$branch = 'main';
$repoName = '';
$prerelease = false;
use MokoCli\{CliFramework, SourceResolver};
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 ($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
class ReleaseCreateCli extends CliFramework
{
$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;
protected function configure(): void
{
$this->setDescription('Create or overwrite a Gitea release with proper naming');
$this->addArgument('--path', 'Repo root for manifest detection (default: .)', '.');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--tag', 'Release tag name (required)', '');
$this->addArgument('--token', 'Gitea API token (required)', '');
$this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
$this->addArgument('--branch', 'Target commitish (default: main)', 'main');
$this->addArgument('--repo', 'Repo name for fallback element detection', '');
$this->addArgument('--prerelease', 'Mark release as prerelease', false);
}
$decoded = json_decode($response, true);
return is_array($decoded) ? $decoded : null;
}
protected function run(): int
{
$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 ─────────────────────────────────────────────────
$root = realpath($path) ?: $path;
$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]);
}
// <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)));
// Allow token from environment
if ($token === '') {
$envToken = getenv('MOKOGITEA_TOKEN');
if ($envToken === false || $envToken === '') {
$envToken = getenv('GITEA_TOKEN');
}
if ($envToken !== false && $envToken !== '') {
$token = $envToken;
}
}
// Human-readable name
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
$extName = trim($nm[1]);
if ($version === '' || $tag === '' || $token === '' || $apiBase === '') {
$this->log('ERROR', "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]");
$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:
$extType = 'dolibarr-module';
$modBasename = basename($modFile, '.class.php');
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename);
// ── Detect element metadata ─────────────────────────────────────────────
$modContent = file_get_contents($modFile);
if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) {
$extName = $nm2[1];
$root = realpath($path) ?: $path;
$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:
$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 ──────────────────────────────────────────────────────
// 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 extension manifest (Joomla XML)
$extManifest = null;
$manifestFiles = array_merge(
SourceResolver::globSource($root, 'pkg_*.xml'),
SourceResolver::globSource($root, '*.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(
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) ─────────────────────────────
$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);
$app = new ReleaseCreateCli();
exit($app->execute());
+154 -218
View File
@@ -1,239 +1,175 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_manage.php
* 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);
$action = null;
$tag = null;
$name = null;
$body = null;
$bodyFile = null;
$target = 'main';
$files = [];
$token = null;
$apiBase = null;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
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];
}
use MokoCli\CliFramework;
// Allow token from environment
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
class ReleaseManageCli extends CliFramework
{
$ch = curl_init($url);
$headers = ["Authorization: token {$token}"];
protected function configure(): void
{
$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 = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CUSTOMREQUEST => $method,
];
protected function run(): int
{
$action = $this->getArgument('--action');
$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) {
$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);
}
private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
{
$ch = curl_init($url);
$headers = ["Authorization: token {$token}"];
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
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;
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response ?: '{}', true) ?: [];
return ['code' => $httpCode, 'data' => $data];
private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
{
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
}
}
/**
* Get release by tag
*/
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);
$app = new ReleaseManageCli();
exit($app->execute());
+223 -276
View File
@@ -6,295 +6,242 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_mirror.php
* 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);
// ── Argument parsing ─────────────────────────────────────────────────────────
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
$version = null;
$tag = null;
$token = null;
$apiBase = null;
$ghToken = null;
$ghRepo = null;
$branch = 'main';
use MokoCli\CliFramework;
foreach ($argv as $i => $arg) {
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
class ReleaseMirrorCli extends CliFramework
{
$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;
}
/**
* 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;
protected function configure(): void
{
$this->setDescription('Mirror a Gitea release (with assets) to a GitHub repository');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--tag', 'Release tag name (required)', '');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
$this->addArgument('--gh-token', 'GitHub personal access token', '');
$this->addArgument('--gh-repo', 'GitHub org/repo (required)', '');
$this->addArgument('--branch', 'Target branch (default: main)', 'main');
}
$localPath = "{$tmpDir}/{$name}";
echo " Downloading: {$name}\n";
protected function run(): int
{
$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)) {
fwrite(STDERR, " Failed to download: {$name}\n");
continue;
// Allow tokens from environment
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: ''));
$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 ───────────────────────────────────────
echo " Uploading: {$name}\n";
$code = githubUploadAsset($uploadUrl, $ghToken, $localPath, $name);
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
echo " {$status}\n";
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;
}
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 ──────────────────────────────────────────────────────────────────
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);
$app = new ReleaseMirrorCli();
exit($app->execute());
+65 -48
View File
@@ -1,66 +1,83 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_notes.php
* BRIEF: Extract release notes from CHANGELOG.md for a given version
*/
declare(strict_types=1);
$path = '.';
$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];
}
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
if ($version === null) {
// 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];
use MokoCli\CliFramework;
class ReleaseNotesCli extends CliFramework
{
protected function configure(): void
{
$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) {
fwrite(STDERR, "Usage: release_notes.php --path . --version XX.YY.ZZ\n");
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);
$app = new ReleaseNotesCli();
exit($app->execute());
+632 -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
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_promote.php
* 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);
$from = null;
$to = null;
$token = null;
$apiBase = null;
$path = '.';
$branch = 'main';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
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];
}
}
use MokoCli\{CliFramework, SourceResolver};
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
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
class ReleasePromoteCli extends CliFramework
{
$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);
protected function configure(): void
{
$this->setDescription('Promote a Gitea release from one channel to another');
$this->addArgument('--from', 'Source channel (or "auto")', '');
$this->addArgument('--to', 'Target channel (required)', '');
$this->addArgument('--token', 'Gitea API token', '');
$this->addArgument('--api-base', 'Gitea API base URL for the repo', '');
$this->addArgument('--path', 'Repository root for type prefix detection', '.');
$this->addArgument('--branch', 'Target branch', 'main');
}
$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;
}
protected function run(): int
{
$from = $this->getArgument('--from') ?: 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
{
$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;
}
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
// ── Resolve --from auto ──────────────────────────────────────────────────────
if ($from === 'auto') {
foreach ($channelOrder as $candidate) {
$data = 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";
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;
if ($to === null || $token === null || $apiBase === null) {
$this->log('ERROR', "Usage: release_promote.php --from <channel|auto> --to <channel> --token TOKEN --api-base URL [--path .]");
$this->log('ERROR', " --from auto: checks beta > alpha > development");
return 1;
}
$extType = '';
$extFolder = '';
if (preg_match('/type="([^"]*)"/', $xmlContent, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xmlContent, $gm)) {
$extFolder = $gm[1];
// ── Suffix maps ──────────────────────────────────────────────────────────────
$suffixMap = [
'development' => '-dev',
'alpha' => '-alpha',
'beta' => '-beta',
'release-candidate' => '-rc',
'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) {
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;
// ── Find source release ──────────────────────────────────────────────────────
$sourceRelease = $this->giteaApi("{$apiBase}/releases/tags/{$from}", $token);
if (!$sourceRelease || empty($sourceRelease['id'])) {
$this->log('ERROR', "No release found with tag: {$from}");
return 1;
}
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 ────────────────────────────────────────────────────────────
$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 ──────────────────────────────────────────────
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);
$app = new ReleasePromoteCli();
exit($app->execute());
+361 -321
View File
@@ -1,349 +1,389 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_publish.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* 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);
$path = '.';
$stability = '';
$token = '';
$bumpType = 'none';
$branch = '';
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
$org = getenv('GITEA_ORG') ?: '';
$repo = getenv('GITEA_REPO') ?: '';
$dryRun = false;
$repoUrl = '';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
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;
}
use MokoCli\CliFramework;
if (empty($stability) || empty($token)) {
fwrite(STDERR, "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]\n");
exit(1);
}
$cli = __DIR__;
$php = '"' . PHP_BINARY . '"';
$giteaUrl = rtrim($giteaUrl, '/');
// Resolve path early for shell commands (Windows needs native paths)
$resolvedPath = realpath($path) ?: $path;
// Auto-detect org/repo from git remote if not set
if (empty($org) || empty($repo)) {
$remote = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "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];
}
}
// 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));
class ReleasePublishCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Publish a release and update stability streams');
$this->addArgument('--path', 'Repository root (default: .)', '.');
$this->addArgument('--stability', 'Target stability: dev|alpha|beta|rc|stable (required)', '');
$this->addArgument('--token', 'Gitea API token (required)', '');
$this->addArgument('--bump', 'Version bump type: patch|minor|none (default: none)', 'none');
$this->addArgument('--branch', 'Current branch (default: auto-detect)', '');
$this->addArgument('--gitea-url', 'Gitea URL', '');
$this->addArgument('--org', 'Organization', '');
$this->addArgument('--repo', 'Repository name', '');
$this->addArgument('--repo-url', 'Repository URL for git auth', '');
$this->addArgument('--skip-update-stream', 'Skip updates.xml generation and sync (managed externally)', false);
}
// Ensure we're on the actual branch (not detached HEAD from PR merge)
@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");
protected function run(): int
{
$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
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((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];
if (empty($stability) || empty($token)) {
$this->log('ERROR', "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]");
return 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];
$cli = __DIR__;
$php = '"' . PHP_BINARY . '"';
$giteaUrl = rtrim($giteaUrl, '/');
// Resolve path early for shell commands (Windows needs native paths)
$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 --
// Each stream gets its own ZIP with the correct version INSIDE templateDetails.xml.
// Joomla reads version from the ZIP after install, so it must match.
echo "\n--- Step 4: Build packages for lesser streams ---\n";
for ($i = 0; $i < $stabilityIndex; $i++) {
$lesserStability = $allStabilities[$i];
$lesserTag = $releaseTagMap[$lesserStability];
$lesserVersion = $baseVersion . $suffixMap[$lesserStability];
// 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";
}
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) {
// 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");
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
// Create release tag
passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($lesserVersion)
. " --tag " . escapeshellarg($lesserTag)
. " --token " . escapeshellarg($token)
. " --api-base " . escapeshellarg($apiBase)
. " --repo " . escapeshellarg($repo)
. " --branch " . escapeshellarg($branch) . " 2>&1");
// 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',
];
// Build and upload package (ZIP will contain the lesser version)
passthru("{$php} {$cli}/release_package.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($lesserVersion)
. " --tag " . escapeshellarg($lesserTag)
. " --token " . escapeshellarg($token)
. " --api-base " . escapeshellarg($apiBase)
. " --repo " . escapeshellarg($repo)
. " --output /tmp 2>&1");
} else {
echo " [DRY-RUN] Would build {$lesserVersion} ZIP and upload to {$lesserTag}\n";
$stabilityIndex = array_search($stability, $allStabilities);
if ($stabilityIndex === false) {
$this->log('ERROR', "Invalid stability: {$stability}");
return 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 (!$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];
// For non-Joomla platforms, use semver tags instead of stream tags
$platformOutput = [];
exec("{$php} {$cli}/manifest_read.php --path " . escapeshellarg($resolvedPath) . " --field platform 2>/dev/null", $platformOutput);
$platform = trim($platformOutput[0] ?? '');
if ($platform !== '' && !str_starts_with($platform, 'joomla')) {
$releaseTag = 'v' . $releaseVersion;
echo " Platform: {$platform} — using semver tag: {$releaseTag}
";
}
$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
if (!$dryRun && $stabilityIndex > 0) {
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);
$app = new ReleasePublishCli();
exit($app->execute());
+210 -227
View File
@@ -6,252 +6,235 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_validate.php
* 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
* BRIEF: Pre-release validation -- version consistency, required files, manifest checks
*/
declare(strict_types=1);
$path = '.';
$version = null;
$platform = null;
$outputSummary = false;
$githubOutput = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
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 ($arg === '--platform' && isset($argv[$i + 1])) {
$platform = $argv[$i + 1];
}
if ($arg === '--output-summary') {
$outputSummary = true;
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
use MokoCli\{CliFramework, SourceResolver};
if ($version === null) {
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
class ReleaseValidateCli extends CliFramework
{
global $pass, $fail, $warn, $results;
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') {
$pass++;
} elseif ($status === 'FAIL') {
$fail++;
} elseif ($status === 'WARN') {
$warn++;
private int $pass = 0;
private int $fail = 0;
private int $warn = 0;
private array $results = [];
protected function configure(): void
{
$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
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
if ($hasSource) {
addResult('Source directory', 'PASS', 'src/ or htdocs/ found');
} else {
addResult('Source directory', 'WARN', 'No src/ or htdocs/ directory');
}
// 1. README.md exists and contains VERSION
if (!file_exists("{$root}/README.md")) {
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;
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$platform = $this->getArgument('--platform');
$outputSummary = (bool) $this->getArgument('--output-summary');
$githubOutput = (bool) $this->getArgument('--github-output');
if ($version === null) {
$this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]");
return 1;
}
foreach (glob("{$dir}/*.xml") as $xmlFile) {
$content = file_get_contents($xmlFile);
if (strpos($content, '<extension') !== false) {
$manifest = $xmlFile;
break 2;
$root = realpath($path) ?: $path;
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]);
}
}
if (in_array($platform, ['waas-component'], true)) {
$platform = 'joomla';
}
if (in_array($platform, ['crm-module'], true)) {
$platform = 'dolibarr';
}
if ($platform === null) {
$platform = 'generic';
}
}
}
if ($manifest === null) {
addResult('XML manifest', 'FAIL', 'No Joomla manifest found');
} else {
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
$mVer = trim($m[1]);
if ($mVer === $version) {
addResult('Manifest version', 'PASS', "`{$mVer}` matches");
$hasSource = SourceResolver::resolveAbsolute($root) !== null;
SourceResolver::warnIfLegacy($root);
$srcDirName = SourceResolver::resolve($root);
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? "{$srcDirName}/ found" : 'No source/ or src/ directory');
if (!file_exists("{$root}/README.md")) {
$this->addVResult('README.md', 'FAIL', 'Not found');
} else {
$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 {
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
if (!file_exists("{$root}/updates.xml")) {
addResult('updates.xml', 'WARN', 'Not found');
} else {
$ux = file_get_contents("{$root}/updates.xml");
if (preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux)) {
addResult('updates.xml version', 'PASS', "`{$version}` found");
} else {
addResult('updates.xml version', 'FAIL', "`{$version}` not in updates.xml");
}
}
} 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));
private function addVResult(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++;
}
}
}
// 5. composer.json version (if present)
if (file_exists("{$root}/composer.json")) {
$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);
$app = new ReleaseValidateCli();
exit($app->execute());
+193 -172
View File
@@ -1,188 +1,209 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_verify.php
* 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);
$zipPath = null;
$version = null;
$platform = 'joomla';
$updatesXml = null;
$githubOutput = false;
$outputSummary = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
if ($arg === '--zip-path' && isset($argv[$i + 1])) $zipPath = $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 === '--updates-xml' && isset($argv[$i + 1])) $updatesXml = $argv[$i + 1];
if ($arg === '--github-output') $githubOutput = true;
if ($arg === '--output-summary') $outputSummary = true;
use MokoCli\CliFramework;
class ReleaseVerifyCli extends CliFramework
{
private int $pass = 0;
private int $fail = 0;
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) {
fwrite(STDERR, "Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]\n");
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);
$app = new ReleaseVerifyCli();
exit($app->execute());
+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
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -7,244 +8,128 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/scaffold_client.php
* VERSION: 09.21.00
* VERSION: 09.38.05
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
*/
declare(strict_types=1);
final class ScaffoldClient
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
class ScaffoldClientCli extends CliFramework
{
private string $name = '';
private string $org = '';
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = '';
private bool $dryRun = false;
protected function configure(): void
{
$this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
$this->addArgument('--name', 'Client name', '');
$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
{
$this->parseArgs();
protected function run(): int
{
$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 === '')
{
$this->log('ERROR: --name, --org, and --token are required.');
$this->printUsage();
return 1;
}
private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void
{
fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\n"
. "Navigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\n"
. "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;
$this->log("Scaffolding client repo: {$this->org}/{$repoName}");
$this->log("Gitea URL: {$this->giteaUrl}");
if ($this->dryRun)
{
$this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
$this->log("[DRY RUN] Repo: {$this->org}/{$repoName}");
$this->log("[DRY RUN] Description: \"{$this->name} WaaS site\"");
$this->log('[DRY RUN] Would create dev branch from main');
$this->printPostSetupInstructions($repoName);
return 0;
}
// Step 1: Create repo from template
$this->log('Step 1: Creating repo from template...');
$createPayload = json_encode([
'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);
}
private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
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];
}
}
$app = new ScaffoldClient();
exit($app->run());
$app = new ScaffoldClientCli();
exit($app->execute());
+164 -159
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -7,175 +8,179 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/sync_rulesets.php
* 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);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\Config;
use MokoEnterprise\PlatformAdapterFactory;
use MokoCli\CliFramework;
use MokoCli\Config;
use MokoCli\PlatformAdapterFactory;
$dryRun = in_array('--dry-run', $argv);
$deleteOld = in_array('--delete', $argv);
class SyncRulesetsCli extends CliFramework
{
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) {
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; }
$config = Config::load();
$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();
$adapter = PlatformAdapterFactory::create($config);
$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);
$app = new SyncRulesetsCli();
exit($app->execute());
+169 -190
View File
@@ -1,209 +1,188 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/theme_lint.php
* 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)
* BRIEF: Lint theme files -- CSS syntax, image sizes, hardcoded URLs
*/
declare(strict_types=1);
$path = '.';
$maxImageKb = 500;
$ghOutput = false;
$strict = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
foreach ($argv as $i => $arg) {
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;
}
use MokoCli\{CliFramework, SourceResolver};
$root = realpath($path) ?: $path;
$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
class ThemeLintCli extends CliFramework
{
$results = [];
if (!is_dir($dir)) return $results;
protected function configure(): void
{
$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(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
protected function run(): int
{
$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) {
if (fnmatch($pattern, $file->getFilename())) {
$results[] = $file->getPathname();
}
}
$root = realpath($path) ?: $path;
$errors = 0;
$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());
+405 -418
View File
@@ -6,449 +6,367 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* DEFGROUP: mokocli.CLI
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/updates_xml_build.php
* BRIEF: Generate Joomla updates.xml from extension manifest metadata
*
* Usage:
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --sha SHA256
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --github-output
*
* Options:
* --path Repository root (default: .)
* --version Version string (required)
* --stability One of: stable, rc, beta, alpha, development (default: stable)
* --sha SHA-256 hash of the ZIP package (optional)
* --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech)
* --org Organization (default: env GITEA_ORG)
* --repo Repository name (default: env GITEA_REPO)
* --output Output file path (default: updates.xml in --path)
* --github-output Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT
*/
declare(strict_types=1);
// -- Argument parsing ---------------------------------------------------------
$path = '.';
$version = null;
$stability = 'stable';
$sha = null;
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
$org = getenv('GITEA_ORG') ?: '';
$repo = getenv('GITEA_REPO') ?: '';
$outputFile = null;
$githubOutput = false;
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
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 ($arg === '--stability' && isset($argv[$i + 1])) {
$stability = $argv[$i + 1];
}
if ($arg === '--sha' && isset($argv[$i + 1])) {
$sha = $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 === '--output' && isset($argv[$i + 1])) {
$outputFile = $argv[$i + 1];
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
use MokoCli\{CliFramework, SourceResolver};
if ($version === null) {
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n");
exit(1);
}
class UpdatesXmlBuildCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Generate Joomla updates.xml from extension manifest metadata');
$this->addArgument('--path', 'Repository root (default: .)', '.');
$this->addArgument('--version', 'Version string (required)', '');
$this->addArgument('--stability', 'One of: stable, rc, beta, alpha, development (default: stable)', 'stable');
$this->addArgument('--sha', 'SHA-256 hash of the ZIP package', '');
$this->addArgument('--gitea-url', 'Gitea instance URL', '');
$this->addArgument('--org', 'Organization', '');
$this->addArgument('--repo', 'Repository name', '');
$this->addArgument('--output', 'Output file path (default: updates.xml in --path)', '');
$this->addArgument('--github-output', 'Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT', false);
}
// Strip any existing stability suffix from version (e.g. 01.02.20-dev → 01.02.20)
// so per-channel suffixes are applied cleanly without doubling
$version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version);
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$stability = $this->getArgument('--stability');
$sha = $this->getArgument('--sha') ?: null;
$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') ?: '');
$outputFile = $this->getArgument('--output') ?: null;
$githubOutput = $this->getArgument('--github-output');
$root = realpath($path) ?: $path;
// -- Read platform from .mokogitea/manifest.xml --------------------------------
$detectedPlatform = 'joomla'; // default for backward compat
$detectedName = $repo;
$detectedPackageType = '';
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) {
$mokoXml = @simplexml_load_file($mokoManifest);
if ($mokoXml !== false) {
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
if ($rawPlatform !== '') {
$detectedPlatform = match ($rawPlatform) {
'waas-component' => 'joomla',
'crm-module' => 'dolibarr',
default => $rawPlatform,
};
if ($version === '') {
$this->log('ERROR', 'Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]');
return 1;
}
$detectedName = (string)($mokoXml->identity->name ?? $repo);
// <display-name> is the human-friendly name for releases and updates.xml
$detectedDisplayName = (string)($mokoXml->identity->{"display-name"} ?? '');
$detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? '');
}
}
// -- Locate Joomla manifest ---------------------------------------------------
$manifest = null;
// Strip suffix — stability is applied via --stability parameter
$version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version);
// Priority: pkg_*.xml in src/ > any extension XML in src/ > any in root
$candidates = glob("{$root}/src/pkg_*.xml") ?: [];
foreach ($candidates as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
$root = realpath($path) ?: $path;
if ($manifest === null) {
$searchDirs = ["{$root}/src", "{$root}"];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) {
continue;
// -- Read platform from .mokogitea/manifest.xml --------------------------------
$detectedPlatform = 'joomla';
$detectedName = $repo;
$detectedPackageType = '';
$detectedDisplayName = '';
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) {
$mokoXml = @simplexml_load_file($mokoManifest);
if ($mokoXml !== false) {
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
if ($rawPlatform !== '') {
$detectedPlatform = match ($rawPlatform) {
'waas-component' => 'joomla',
'crm-module' => 'dolibarr',
default => $rawPlatform,
};
}
$detectedName = (string)($mokoXml->identity->name ?? $repo);
$detectedDisplayName = (string)($mokoXml->identity->{"display-name"} ?? '');
$detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? '');
if (empty($org)) {
$manifestOrg = (string)($mokoXml->identity->org ?? '');
if ($manifestOrg !== '') {
$org = $manifestOrg;
}
}
if (empty($repo)) {
$manifestName = (string)($mokoXml->identity->name ?? '');
if ($manifestName !== '') {
$repo = $manifestName;
}
}
}
}
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
// -- Fallback: detect org/repo from git remote --------------------------------
if (empty($org) || empty($repo)) {
$remoteUrl = trim(shell_exec("git -C " . escapeshellarg($root) . " remote get-url origin 2>/dev/null") ?? '');
if (preg_match('#[/:]([^/:]+)/([^/]+?)(?:\.git)?$#', $remoteUrl, $m)) {
if (empty($org)) {
$org = $m[1];
}
if (empty($repo)) {
$repo = $m[2];
}
}
}
// -- Locate Joomla manifest ---------------------------------------------------
$manifest = null;
$candidates = SourceResolver::globSource($root, 'pkg_*.xml');
foreach ($candidates as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break 2;
break;
}
}
}
}
if ($manifest === null && $detectedPlatform === 'joomla') {
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
exit(1);
}
if ($manifest === null) {
$searchDirs = ["{$root}/src", "{$root}"];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break 2;
}
}
}
}
// -- Parse extension metadata -------------------------------------------------
$extName = '';
$extType = '';
$extElement = '';
$extClient = '';
$extFolder = '';
$targetPlatform = '';
$phpMinimum = '';
if ($manifest === null && $detectedPlatform === 'joomla') {
$this->log('ERROR', "No Joomla XML manifest found in {$root}");
return 1;
}
if ($manifest !== null) {
// Joomla manifest found — parse extension metadata from it
$xml = file_get_contents($manifest);
// -- Parse extension metadata -------------------------------------------------
$extName = '';
$extType = '';
$extElement = '';
$extClient = '';
$extFolder = '';
$targetPlatform = '';
$phpMinimum = '';
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
$extName = $m[1];
}
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
$extType = $m[1];
}
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
$extElement = $m[1];
}
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) {
$extElement = $m[1];
}
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) {
$extElement = $m[1];
}
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) {
$extElement = $m[1];
}
if (empty($extElement)) {
$fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
if (in_array($fname, ['templatedetails', 'manifest'])) {
$extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root)));
if ($manifest !== null) {
$xml = file_get_contents($manifest);
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
$extName = $m[1];
}
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
$extType = $m[1];
}
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
$extElement = $m[1];
}
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) {
$extElement = $m[1];
}
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) {
$extElement = $m[1];
}
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) {
$extElement = $m[1];
}
if (empty($extElement)) {
$fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
if (in_array($fname, ['templatedetails', 'manifest'])) {
$extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root)));
} else {
$extElement = $fname;
}
}
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) {
$extClient = $m[1];
}
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
$extFolder = $m[1];
}
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) {
$targetPlatform = $m[1];
}
if (empty($targetPlatform)) {
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
}
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) {
$phpMinimum = $m[1];
}
} else {
$extElement = $fname;
$extName = $detectedName ?: ($repo ?: basename($root));
$extElement = strtolower(str_replace([' ', '-'], '', $extName));
$extType = $detectedPackageType ?: 'generic';
$targetPlatform = "<targetplatform name=\"{$detectedPlatform}\" version=\".*\" />";
}
}
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) {
$extClient = $m[1];
}
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
$extFolder = $m[1];
}
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) {
$targetPlatform = $m[1];
}
if (empty($targetPlatform)) {
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
}
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) {
$phpMinimum = $m[1];
}
} else {
// Non-Joomla platform — derive metadata from .mokogitea/manifest.xml
$extName = $detectedName ?: ($repo ?: basename($root));
$extElement = strtolower(str_replace([' ', '-'], '', $extName));
$extType = $detectedPackageType ?: 'generic';
$targetPlatform = "<targetplatform name=\"{$detectedPlatform}\" version=\".*\" />";
}
// Display name resolution moved to manifest.xml <display-name> (below)
// Fallbacks
if (empty($extName)) {
$extName = $repo ?: basename($root);
}
if (empty($extType)) {
$extType = 'component';
}
// Display name: use <display-name> from manifest.xml if available
// This is the canonical human-friendly name — no type prefix added
if (!empty($detectedDisplayName)) {
$displayName = $detectedDisplayName;
} elseif (!empty($detectedName)) {
$displayName = $detectedName;
} else {
$displayName = $extName;
}
// -- Build type prefix --------------------------------------------------------
$typePrefix = '';
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;
}
// -- Export to GITHUB_OUTPUT if requested -------------------------------------
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"ext_element={$extElement}",
"ext_name={$extName}",
"ext_type={$extType}",
"ext_folder={$extFolder}",
"type_prefix={$typePrefix}",
];
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";
if (empty($extName)) {
$extName = $repo ?: basename($root);
}
if (empty($extType)) {
$extType = 'component';
}
}
}
// -- Stability suffix map -----------------------------------------------------
$stabilitySuffixMap = [
'stable' => '',
'rc' => '-rc',
'beta' => '-beta',
'alpha' => '-alpha',
'development' => '-dev',
'dev' => '-dev',
];
if (!empty($detectedDisplayName)) {
$displayName = $detectedDisplayName;
} elseif (!empty($detectedName)) {
$displayName = $detectedName;
} else {
$displayName = $extName;
}
// Joomla <tags><tag> values — maps to Joomla's stabilityTagToInteger()
$stabilityTagMap = [
'stable' => 'stable',
'rc' => 'rc',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'dev',
'dev' => 'dev',
];
// -- Build type prefix --------------------------------------------------------
$typePrefix = '';
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;
}
// Gitea release tag names (used in download/info URLs)
$releaseTagMap = [
'stable' => 'stable',
'rc' => 'release-candidate',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'development',
'dev' => 'development',
];
// -- Build update entries -----------------------------------------------------
// For the primary entry: apply suffix if not stable
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
$primaryVersion = $version . $primarySuffix;
// Build client tag — Joomla requires <client>site</client> to match updates
// to installed extensions. Without it, extension_id=0 in #__updates.
$clientTag = '';
if (!empty($extClient)) {
$clientTag = " <client>{$extClient}</client>";
} else {
$clientTag = ' <client>site</client>';
}
// Build folder tag
$folderTag = '';
if (!empty($extFolder) && $extType === 'plugin') {
$folderTag = " <folder>{$extFolder}</folder>";
}
// PHP minimum tag
$phpTag = '';
if (!empty($phpMinimum)) {
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
}
// SHA tag
$shaTag = '';
if (!empty($sha)) {
$shaTag = " <sha256>{$sha}</sha256>";
}
/**
* Build a single <update> entry for a given stability tag
*/
function buildEntry(
string $tagName,
string $entryVersion,
string $entryDownloadUrl,
string $displayName,
string $stabilityLabel,
string $extElement,
string $extType,
string $clientTag,
string $folderTag,
string $infoUrl,
string $targetPlatform,
string $phpTag,
string $shaTag,
string $changelogUrl = ''
): string {
$lines = [];
$lines[] = ' <update>';
$lines[] = " <name>{$displayName}</name>";
$lines[] = " <description>{$displayName} {$stabilityLabel} build.</description>";
// Element in updates.xml must match what Joomla stores in #__extensions.
// Plugins and templates are stored as bare element (no prefix).
// Other types need their prefix: mod_, com_, pkg_, lib_.
$prefixMap = [
'package' => 'pkg_',
'module' => 'mod_',
'component' => 'com_',
'library' => 'lib_',
];
$dbElement = isset($prefixMap[$extType]) ? $prefixMap[$extType] . $extElement : $extElement;
$lines[] = " <element>{$dbElement}</element>";
$lines[] = " <type>{$extType}</type>";
$lines[] = $clientTag;
$lines[] = " <version>{$entryVersion}</version>";
$lines[] = " <creationDate>" . date('Y-m-d') . "</creationDate>";
if (!empty($folderTag)) {
$lines[] = $folderTag;
}
$lines[] = " <infourl title='{$displayName}'>{$infoUrl}</infourl>";
$lines[] = ' <downloads>';
$lines[] = " <downloadurl type='full' format='zip'>{$entryDownloadUrl}</downloadurl>";
$lines[] = ' </downloads>';
if (!empty($shaTag)) {
$lines[] = $shaTag;
}
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
if (!empty($changelogUrl)) {
$lines[] = " <changelogurl>{$changelogUrl}</changelogurl>";
}
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
$lines[] = " {$targetPlatform}";
if (!empty($phpTag)) {
$lines[] = $phpTag;
}
$lines[] = ' </update>';
return implode("\n", $lines);
}
// -- Write ONLY the single channel being released --------------------------------
// No cascading. Each update stream is independent.
// When dev releases, only the dev entry is written/updated.
// When stable releases, only the stable entry is written/updated.
// All other channel entries are preserved exactly as-is.
$entries = [];
$giteaTag = $releaseTagMap[$stability] ?? $stability;
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
$joomlaTag = $stabilityTagMap[$stability] ?? $stability;
$changelogUrl = "{$giteaUrl}/{$org}/{$repo}/raw/branch/main/CHANGELOG.md";
$entries[] = buildEntry(
$joomlaTag,
$channelVersion,
$channelDownloadUrl,
$displayName,
$stability,
$extElement,
$extType,
$clientTag,
$folderTag,
$channelInfoUrl,
$targetPlatform,
$phpTag,
$shaTag,
$changelogUrl
);
// -- Preserve existing entries for channels not being updated -----------------
$dest = $outputFile ?? "{$root}/updates.xml";
$preservedEntries = [];
if (file_exists($dest)) {
$existingXml = @simplexml_load_file($dest);
if ($existingXml) {
// Only the channel we're writing gets replaced — everything else is preserved
$writtenTag = $joomlaTag;
// Also match legacy alternate (e.g. 'development' = 'dev')
$writtenAliases = [$writtenTag];
if ($writtenTag === 'dev') $writtenAliases[] = 'development';
if ($writtenTag === 'development') $writtenAliases[] = 'dev';
foreach ($existingXml->update as $existingUpdate) {
$existingTag = '';
if (isset($existingUpdate->tags->tag)) {
$existingTag = (string) $existingUpdate->tags->tag;
}
// Keep ALL entries except the one channel we're overwriting
if (!empty($existingTag) && !in_array($existingTag, $writtenAliases, true)) {
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
// -- Export to GITHUB_OUTPUT if requested -------------------------------------
if ($githubOutput) {
$ghOutput = getenv('GITHUB_OUTPUT');
$lines = [
"ext_element={$extElement}",
"ext_name={$extName}",
"ext_type={$extType}",
"ext_folder={$extFolder}",
"type_prefix={$typePrefix}",
];
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";
}
}
}
}
}
// -- Write updates.xml --------------------------------------------------------
$year = date('Y');
$output = <<<XML
// -- Stability suffix map -----------------------------------------------------
$stabilitySuffixMap = [
'stable' => '',
'rc' => '-rc',
'beta' => '-beta',
'alpha' => '-alpha',
'development' => '-dev',
'dev' => '-dev',
];
$stabilityTagMap = [
'stable' => 'stable',
'rc' => 'rc',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'dev',
'dev' => 'dev',
];
$releaseTagMap = [
'stable' => 'stable',
'rc' => 'release-candidate',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'development',
'dev' => 'development',
];
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
$primaryVersion = $version . $primarySuffix;
$clientTag = '';
if (!empty($extClient)) {
$clientTag = " <client>{$extClient}</client>";
} else {
$clientTag = ' <client>site</client>';
}
$folderTag = '';
if (!empty($extFolder) && $extType === 'plugin') {
$folderTag = " <folder>{$extFolder}</folder>";
}
$phpTag = '';
if (!empty($phpMinimum)) {
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
}
$shaTag = '';
if (!empty($sha)) {
$shaTag = " <sha256>{$sha}</sha256>";
}
// -- Write ONLY the single channel being released --------------------------------
$entries = [];
$giteaTag = $releaseTagMap[$stability] ?? $stability;
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
$joomlaTag = $stabilityTagMap[$stability] ?? $stability;
$changelogUrl = "{$giteaUrl}/{$org}/{$repo}/raw/branch/main/CHANGELOG.md";
$entries[] = $this->buildEntry(
$joomlaTag,
$channelVersion,
$channelDownloadUrl,
$displayName,
$stability,
$extElement,
$extType,
$clientTag,
$folderTag,
$channelInfoUrl,
$targetPlatform,
$phpTag,
$shaTag,
$changelogUrl
);
// -- Preserve existing entries for channels not being updated -----------------
$dest = $outputFile ?? "{$root}/updates.xml";
$preservedEntries = [];
if (file_exists($dest)) {
$existingXml = @simplexml_load_file($dest);
if ($existingXml) {
$writtenTag = $joomlaTag;
$writtenAliases = [$writtenTag];
if ($writtenTag === 'dev') {
$writtenAliases[] = 'development';
}
if ($writtenTag === 'development') {
$writtenAliases[] = 'dev';
}
foreach ($existingXml->update as $existingUpdate) {
$existingTag = '';
if (isset($existingUpdate->tags->tag)) {
$existingTag = (string) $existingUpdate->tags->tag;
}
if (!empty($existingTag) && !in_array($existingTag, $writtenAliases, true)) {
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
}
}
}
}
// -- Write updates.xml --------------------------------------------------------
$year = date('Y');
$output = <<<XML
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) {$year} Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
@@ -457,13 +375,82 @@ $output = <<<XML
<updates>
XML;
$allEntries = array_merge($preservedEntries, $entries);
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
$allEntries = array_merge($preservedEntries, $entries);
$dest = $outputFile ?? "{$root}/updates.xml";
file_put_contents($dest, $output);
$stabilityOrder = ['dev' => 0, 'development' => 0, 'alpha' => 1, 'beta' => 2, 'rc' => 3, 'stable' => 4];
usort($allEntries, function ($a, $b) use ($stabilityOrder) {
preg_match('/<tag>([^<]+)<\/tag>/', $a, $ma);
preg_match('/<tag>([^<]+)<\/tag>/', $b, $mb);
return ($stabilityOrder[$ma[1] ?? ''] ?? 99) - ($stabilityOrder[$mb[1] ?? ''] ?? 99);
});
$channelCount = count($entries);
echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n";
echo "Output: {$dest}\n";
exit(0);
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
$dest = $outputFile ?? "{$root}/updates.xml";
file_put_contents($dest, $output);
$channelCount = count($entries);
echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n";
echo "Output: {$dest}\n";
return 0;
}
private function buildEntry(
string $tagName,
string $entryVersion,
string $entryDownloadUrl,
string $displayName,
string $stabilityLabel,
string $extElement,
string $extType,
string $clientTag,
string $folderTag,
string $infoUrl,
string $targetPlatform,
string $phpTag,
string $shaTag,
string $changelogUrl = ''
): string {
$lines = [];
$lines[] = ' <update>';
$lines[] = " <name>{$displayName}</name>";
$lines[] = " <description>{$displayName} {$stabilityLabel} build.</description>";
$prefixMap = [
'package' => 'pkg_',
'module' => 'mod_',
'component' => 'com_',
'library' => 'lib_',
];
$dbElement = isset($prefixMap[$extType]) ? $prefixMap[$extType] . $extElement : $extElement;
$lines[] = " <element>{$dbElement}</element>";
$lines[] = " <type>{$extType}</type>";
$lines[] = $clientTag;
$lines[] = " <version>{$entryVersion}</version>";
$lines[] = " <creationDate>" . date('Y-m-d') . "</creationDate>";
if (!empty($folderTag)) {
$lines[] = $folderTag;
}
$lines[] = " <infourl title='{$displayName}'>{$infoUrl}</infourl>";
$lines[] = ' <downloads>';
$lines[] = " <downloadurl type='full' format='zip'>{$entryDownloadUrl}</downloadurl>";
$lines[] = ' </downloads>';
if (!empty($shaTag)) {
$lines[] = $shaTag;
}
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
if (!empty($changelogUrl)) {
$lines[] = " <changelogurl>{$changelogUrl}</changelogurl>";
}
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
$lines[] = " {$targetPlatform}";
if (!empty($phpTag)) {
$lines[] = $phpTag;
}
$lines[] = ' </update>';
return implode("\n", $lines);
}
}
$app = new UpdatesXmlBuildCli();
exit($app->execute());

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