Compare commits

...

53 Commits

Author SHA1 Message Date
Jonathan Miller 1bd170c77f fix: workflow race conditions, pre-release CLI migration, auto-bump dev-only
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 48s
2026-05-26 17:47:19 -05:00
Jonathan Miller 35befccf06 fix: resolve workflow race conditions and remaining gaps
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 45s
Race condition fixes:
- auto-bump.yml: dev-only, skips merge commits (avoids conflict with pre-release)
- auto-release.yml: minor bump restored inline (avoids conflict with auto-bump on main)

auto-release.yml:
- Step 8b: removed 26-line inline fallback, uses release_body_update.php only
- promote-rc: branch defaults to 'dev' when workflow_dispatch (no PR context)

pre-release.yml (328 → 223 lines):
- Replaced 53-line inline build with release_package.php
- Replaced 47-line inline release creation with release_create.php
- Standardized setup step to match auto-release pattern
- Fixed empty $MANIFEST variable output
- SHA256 now sourced from release_package.php output

auto-bump.yml:
- Dev-only (removed main), skips merge commits
- Added FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 env
- Fixed job name to "Version Bump"

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:46:25 -05:00
gitea-actions[bot] d012bb900b chore(version): minor bump to 09.03.00 [skip ci] 2026-05-26 22:34:23 +00:00
jmiller 2ab332161d Merge pull request 'chore: cascade main → dev (a9acb2d) [skip ci]' (#189) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 22:34:22 +00:00
Jonathan Miller a9acb2d27c Merge dev: SHA-256 in updates.xml, centralized auto-bump, Joomla package language fix
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Universal: Auto Version Bump / Patch Bump (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 47s
2026-05-26 17:34:15 -05:00
gitea-actions[bot] e609a4e205 chore(version): patch bump to 09.02.04 [skip ci] 2026-05-26 22:31:57 +00:00
Jonathan Miller dbf4cdef9a fix: Joomla package builds now include language/ and other top-level dirs
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Patch Bump (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 45s
pre-release.yml package build was only copying *.xml and *.php from
src/ root, missing language/, media/, and other non-package directories.

Fixes #187

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:31:47 -05:00
gitea-actions[bot] 24d5238b64 chore(version): minor bump to 09.03.00 [skip ci] 2026-05-26 22:30:52 +00:00
jmiller 4ccefec2dc Merge pull request 'chore: cascade main → dev (5a8b18e) [skip ci]' (#188) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 22:30:50 +00:00
Jonathan Miller 5a8b18ea8d fix: SHA-256 checksums in updates.xml + centralized auto-bump
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Universal: Auto Version Bump / Patch Bump (push) Successful in 5s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 48s
2026-05-26 17:30:43 -05:00
gitea-actions[bot] 9dbf790e1a chore(version): patch bump to 09.02.03 [skip ci] 2026-05-26 22:30:20 +00:00
Jonathan Miller a07d93b6fc fix: pass SHA-256 checksum to updates.xml in both release workflows
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Patch Bump (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 47s
- release_package.php now outputs sha256_zip= to GITHUB_OUTPUT
- auto-release.yml: moved updates.xml write after package build so SHA is available
- pre-release.yml: passes --sha from zip step to updates_xml_build.php
- updates.xml now includes <sha256> tag for Joomla update integrity verification

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:30:12 -05:00
gitea-actions[bot] 415e58d06c chore(version): minor bump to 09.03.00 [skip ci] 2026-05-26 22:22:49 +00:00
jmiller d8ec7b5ba0 Merge pull request 'chore: cascade main → dev (e882425) [skip ci]' (#186) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 22:22:49 +00:00
Jonathan Miller e882425f04 feat: centralized auto-bump workflow for all version bumps
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Universal: Auto Version Bump / Patch Bump (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 50s
2026-05-26 17:22:42 -05:00
gitea-actions[bot] 3171fb3ef0 chore(version): patch bump to 09.02.02 [skip ci] 2026-05-26 22:22:29 +00:00
Jonathan Miller 6cd46f0b7f feat: auto-bump.yml handles all version bumps, removed from release workflows
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Patch Bump (push) Successful in 5s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 48s
auto-bump.yml: patch bump on push to dev, minor bump on push to main
auto-release.yml: removed Step 1b (minor bump) — handled by auto-bump
pre-release.yml: removed patch bump — handled by auto-bump

Version bump is now centralized in one workflow across all branches.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:21:55 -05:00
gitea-actions[bot] 48ae7c1e88 chore(version): patch bump to 09.02.01 [skip ci] 2026-05-26 22:19:23 +00:00
Jonathan Miller 63a2640254 fix: include top-level directories (language/) in Joomla package ZIP
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 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) Successful in 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: Auto Version Bump / Patch Bump (push) Successful in 6s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 54s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 54s
release_package.php was only copying *.xml and *.php from the package
source root, missing the language/ directory. Joomla install fails with
"File does not exist" for package-level language files.

Now includes all top-level directories (except packages/) in the
package ZIP.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:19:15 -05:00
Jonathan Miller 6ec2202c6e fix: include language directory in Joomla package ZIP builds
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 54s
Package-type extensions reference language files in their manifest
(e.g. language/en-GB/pkg_*.sys.ini) but the build only copied
top-level XML/PHP files. Now also adds the language/ directory.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:19:09 -05:00
Jonathan Miller 8f7cce051b docs(changelog): add auto-bump entry [skip bump]
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / 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) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Auto Version Bump / Patch Bump (push) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 47s
2026-05-26 17:15:46 -05:00
Jonathan Miller f426f21f2e feat: auto patch-bump on every push to dev
New workflow auto-bump.yml: runs version_bump.php on every push to dev,
propagates via version_set_platform.php + version_check.php, commits
with [skip ci] to avoid retriggering.

Skips when commit message contains [skip ci] or [skip bump].

Removed redundant patch bump from pre-release.yml — version is already
bumped by the push workflow before pre-release runs.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:11:09 -05:00
Jonathan Miller 2ee5a55ec5 fix: preserve version suffix (-dev/-rc/etc) in version_bump.php
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 43s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Build & Release / Promote Pre-Release to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 44s
version_bump.php was stripping stability suffixes (e.g. -dev, -rc) from
Joomla extension manifest <version> tags during auto-bump. Now captures
the suffix from the source version and re-applies it after incrementing.

manifest.xml and README.md remain suffix-free (canonical bare version).
Only Joomla extension manifests preserve their suffix.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:07:07 -05:00
Jonathan Miller a04040533c docs(changelog): add v09.02.00 release notes
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 1s
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 16:59:25 -05:00
jmiller d5541abf22 Merge pull request 'chore: cascade main → dev (8ae829a) [skip ci]' (#183) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 21:55:38 +00:00
Jonathan Miller 8ae829ad89 feat: add live deploy target with multi-instance support to deploy-module.yml
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 44s
- Add 'live' and 'all' options to server selector
- Live deploy reads LIVE_TARGETS JSON secret for multiple production instances
- Move dev/demo host config from hardcoded env to vars.*
- Add summary step for deploy reporting

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 16:55:32 -05:00
jmiller 5815ad040f Merge pull request 'chore: cascade main → dev (96b6db7) [skip ci]' (#182) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 21:54:46 +00:00
Jonathan Miller 96b6db73a9 Merge dev: workflow CLI refactoring, version_bump package.json/pyproject.toml, v09.02.00
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 46s
2026-05-26 16:54:15 -05:00
jmiller 8eb3e310cf Merge pull request 'chore: cascade main → dev (eca475c) [skip ci]' (#181) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 21:32:24 +00:00
Jonathan Miller eca475c6e3 chore: remove legacy SFTP deploy steps from workflows
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 47s
- Remove SFTP deploy step from update-server.yml
- Delete deploy-manual.yml (manual SFTP deploy workflow)

All deployment now goes through updates.xml and Gitea releases.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 16:31:35 -05:00
Jonathan Miller 92822303ef feat: version_bump/read support package.json and pyproject.toml
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 44s
version_read.php now reads version from:
- .mokogitea/manifest.xml (authoritative)
- README.md, Joomla XML, composer.json (existing)
- package.json (Node.js / MCP repos) — new
- pyproject.toml (Python repos) — new

version_bump.php now writes bumped version to all of the above.

Also bumps moko-platform 09.01.00 → 09.02.00.

Closes #179

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 16:28:40 -05:00
Jonathan Miller 9649fb55cf refactor: replace all inline bash in workflows with CLI tool calls
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 46s
auto-release.yml (761 → 490 lines, -271 lines):
- Sanity checks: release_validate.php replaces 95-line inline bash (#180)
- Step 7b: release_create.php replaces 65-line curl+python3 block (#176)
- Step 8: release_package.php replaces 115-line build+upload block (#173)
- Step 9: release_mirror.php replaces 40-line GitHub mirror block (#175)
- Dolibarr reset: version_reset_dev.php replaces 20-line inline (#174)
- Added workflow_dispatch promote-rc fallback for MokoGitea#220 (#178)

pre-release.yml:
- Fixed updates.xml sync: checkout only updates.xml, not entire tree (#177)

Closes #173 #174 #175 #176 #177 #178 #180

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 16:21:02 -05:00
jmiller 8f39017b59 Merge pull request 'chore: cascade main → dev (bd18642) [skip ci]' (#171) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 20:14:09 +00:00
Jonathan Miller bd18642045 Merge dev: release promotion pipeline, manifest-aware CLI, workflow refactoring
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 46s
- 7 new CLI tools (manifest_element, release_create, release_package,
  release_promote, release_mirror, version_reset_dev, ManifestReader)
- Universal workflows: RC promotion, auto-dev-release, paths filter removed
- CLI tools now read .mokogitea/manifest.xml for platform-aware behavior
- updates_xml_build.php supports non-Joomla platforms
- Version 09.01.00
2026-05-26 15:13:34 -05:00
jmiller 820e968e1a Merge pull request 'chore: cascade main → dev (a5cd566) [skip ci]' (#170) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 20:12:40 +00:00
jmiller a5cd566dea fix: version_bump.php cascades version to all Joomla XML manifests
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 51s
Authored-by: Moko Consulting
2026-05-26 20:12:36 +00:00
jmiller b5599579a7 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-26 20:12:34 +00:00
Jonathan Miller 61a232dfc6 feat: CLI tools now read .mokogitea/manifest.xml for platform-aware behavior
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 56s
New: lib/Enterprise/ManifestReader.php — shared manifest.xml parser with
typed accessors for platform, package-type, entry-point, source-dir.

Updated CLI tools to read manifest.xml:
- updates_xml_build.php: supports non-Joomla platforms (dolibarr, generic,
  mcp) — builds generic updates.xml when no Joomla manifest found
- release_package.php: reads entry-point from manifest.xml for source dir
  resolution instead of hard-coded src/htdocs fallback
- pre-release.yml: replaced 75 lines of inline logic with CLI tool calls
  (manifest_read.php, manifest_element.php, updates_xml_build.php)

Closes #163

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:12:13 -05:00
jmiller a45bf42335 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-26 20:09:07 +00:00
jmiller 77a1ae3977 Merge pull request 'chore: cascade main → dev (fb5461b) [skip ci]' (#169) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 20:08:09 +00:00
jmiller fb5461b661 Merge pull request 'feat: manifest.xml as canonical version source' (#168) from dev into main
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 48s
Merge PR #168: feat: manifest.xml as canonical version source
2026-05-26 20:08:03 +00:00
Jonathan Miller e15421699e feat: manifest.xml as canonical version source
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 50s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 48s
Universal: Build & Release / Promote Pre-Release to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 12s
- version_bump.php: insert <version> tag into manifest.xml if missing
  (placed before <license> per schema order)
- version_read.php: backfill <version> into manifest.xml from fallback
  sources (README.md, Joomla XML) on first read
- manifest-schema.xsd: add version="09.01.00" attribute to xs:schema
- manifest.xml: add <version>, xsi:schemaLocation pointing to schema
  on moko-platform main branch

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:03:52 -05:00
Jonathan Miller 48d574e225 feat: release_mirror.php — mirror Gitea releases to GitHub
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 43s
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
New CLI tool that mirrors a Gitea release (with assets) to a GitHub
repository. Replaces the 40-line inline bash in auto-release.yml Step 9.

Supports create/update, asset download+upload, and proper GitHub API
headers (User-Agent, Accept).

Closes #160

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 14:46:59 -05:00
Jonathan Miller 1dba0c37b9 refactor: pre-release.yml uses CLI tools instead of inline logic
Replace 3 blocks of inline bash/PHP with existing CLI tool calls:
- Platform detection: manifest_read.php --github-output
- Element detection: manifest_element.php --github-output (28 lines → 4 lines)
- updates.xml update: updates_xml_build.php (25 lines → 3 lines)

Removes ~75 lines of duplicated inline logic. Workflow now delegates
all platform-aware logic to moko-platform CLI tools.

Closes #163

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 14:45:26 -05:00
Jonathan Miller 07ea171af9 feat: release promotion pipeline, 5 new CLI tools, workflow refactoring
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 43s
New CLI tools:
- manifest_element.php — extract element/type/prefix from any platform manifest
- release_create.php — create/overwrite Gitea releases with proper naming
- release_package.php — build ZIP+tar.gz, SHA-256, upload assets
- release_promote.php — promote releases between channels (dev→RC→stable)
- version_reset_dev.php — reset platform version on dev branch after release

Updated CLI tools:
- version_bump.php — now writes to manifests, Dolibarr mod, composer.json (not just README)
- release_cascade.php — added --version for version-aware deletion of stale releases
- release_validate.php — auto-detect platform, --github-output, source dir check

Workflow changes (auto-release.yml):
- Draft PR to main → auto-promote highest pre-release to RC
- Merged PR to main → promote RC to stable (skip rebuild when RC exists)
- Removed paths filter for Go/Node/generic repo compatibility
- Fixed cascade --api-base parameter bug

Workflow changes (pre-release.yml):
- Auto-trigger development pre-release on feature branch merge to dev
- Removed paths filter

Infrastructure:
- RepositorySynchronizer: fixed template repo names, .mokogitea/workflows path,
  universal workflow cascade (Template-Generic → other templates)
- bulk_sync.php: syncs universal workflows to templates before repo sync
- PHPDoc added to 4 classes missing class-level docs
- Version bump 09.00.00 → 09.01.00

Closes #152 #153 #154 #155 #156 #157 #158 #159 #161 #162

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 14:29:32 -05:00
jmiller 420b4f5f3c Merge pull request 'chore: cascade main → dev (f8c28f0) [skip ci]' (#166) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 19:28:01 +00:00
jmiller f8c28f055b feat: manifest-schema.xsd — XSD schema for .mokogitea/manifest.xml
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 47s
Authored-by: Moko Consulting
2026-05-26 19:27:57 +00:00
jmiller a7df4d49b9 feat: wiki_sync.php — sync standards wiki pages to template repos
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 50s
Authored-by: Moko Consulting
2026-05-26 19:27:57 +00:00
jmiller 320b2c57be Merge pull request 'chore: cascade main → dev (c5e4b41) [skip ci]' (#165) from main into dev
chore: cascade main → dev [skip ci]
2026-05-26 19:26:49 +00:00
jmiller d323ca52af feat: version_bump.php writes to .mokogitea/manifest.xml as canonical target
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 3s
Generic: Repo Health / Access control (push) Successful in 2s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 53s
Authored-by: Moko Consulting
2026-05-26 19:26:45 +00:00
jmiller c5e4b41100 feat: version_read.php uses .mokogitea/manifest.xml as canonical version source
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Universal: Cascade Main → Dev / Cascade main → branches (push) Failing after 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 53s
Authored-by: Moko Consulting
2026-05-26 19:26:45 +00:00
jmiller 335fcd0382 feat: manifest_read.php adds version field from identity block
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 52s
Authored-by: Moko Consulting
2026-05-26 19:26:44 +00:00
jmiller c1c820bb5c chore: add .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-26 19:03:58 +00:00
41 changed files with 4763 additions and 1127 deletions
+5 -1
View File
@@ -4,11 +4,15 @@
Auto-generated by cleanup script.
See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home
-->
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://standards.mokoconsulting.tech/moko-platform/1.0 https://git.mokoconsulting.tech/MokoConsulting/moko-platform/raw/branch/main/definitions/manifest-schema.xsd"
schema-version="1.0">
<identity>
<name>moko-platform</name>
<org>MokoConsulting</org>
<description>Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories</description>
<version>09.01.00</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+84
View File
@@ -0,0 +1,84 @@
# 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
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.GA_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.GA_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: |
BUMP=$(php ${MOKO_CLI}/version_bump.php --path . 2>&1) || true
echo "$BUMP"
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null) || true
[ -z "$VERSION" ] && { echo "No version found — skipping"; exit 0; }
# Propagate to platform manifests
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch dev 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Commit if anything changed
if git diff --quiet && git diff --cached --quiet; then
echo "No version changes to commit"
exit 0
fi
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git commit -m "chore(version): patch bump to ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push origin dev
echo "Bumped to ${VERSION}" >> $GITHUB_STEP_SUMMARY
+178 -352
View File
@@ -27,13 +27,19 @@ name: "Universal: Build & Release"
on:
pull_request:
types: [closed]
types: [opened, closed]
branches:
- main
paths:
- 'src/**'
- 'htdocs/**'
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
@@ -45,11 +51,66 @@ permissions:
contents: write
jobs:
# ── Draft PR → Promote highest pre-release to RC ─────────────────────────────
promote-rc:
name: Promote Pre-Release to RC
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.draft == true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_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
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: Promote to release-candidate
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_promote.php \
--from auto --to release-candidate \
--token "${{ secrets.GA_TOKEN }}" \
--api-base "${API_BASE}" \
--branch "${{ github.event.pull_request.head.ref || 'dev' }}"
- name: Cascade lesser channels
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_cascade.php \
--stability release-candidate \
--token "${{ secrets.GA_TOKEN }}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Draft PR opened — promoted highest pre-release to RC" >> $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'
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
@@ -100,14 +161,34 @@ jobs:
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "branch=main" >> "$GITHUB_OUTPUT"
- name: "Step 1b: Bump version"
id: bump
# -- CHECK FOR RC PROMOTION ------------------------------------------------
- name: "Check for RC release"
id: rc
if: steps.version.outputs.skip != 'true'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
RC_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/release-candidate" 2>/dev/null || echo "{}")
RC_ID=$(echo "$RC_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
if [ -n "$RC_ID" ] && [ "$RC_ID" != "None" ] && [ "$RC_ID" != "" ]; then
echo "promote=true" >> "$GITHUB_OUTPUT"
echo "release_id=${RC_ID}" >> "$GITHUB_OUTPUT"
echo "::notice::RC release found (id: ${RC_ID}) — will promote to stable"
else
echo "promote=false" >> "$GITHUB_OUTPUT"
echo "::notice::No RC release — full build pipeline"
fi
- name: "Step 1b: Minor bump version"
id: bump
if: >-
steps.version.outputs.skip != 'true' &&
steps.rc.outputs.promote != 'true'
run: |
MOKO_API="/tmp/moko-platform-api/cli"
BUMP=$(php ${MOKO_API}/version_bump.php --path . --minor)
VERSION=$(echo "$BUMP" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true)
[ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .)
php ${MOKO_API}/version_bump.php --path . --minor 2>&1 || true
VERSION=$(php ${MOKO_API}/version_read.php --path .)
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Bumped to: ${VERSION}"
@@ -137,95 +218,8 @@ jobs:
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
ERRORS=0
PLATFORM="${{ steps.platform.outputs.platform }}"
MANIFEST="${{ steps.platform.outputs.manifest }}"
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
echo "## Pre-Release Sanity Checks (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# -- Version drift check (must pass before release) --------
README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
if [ "$README_VER" != "$VERSION" ]; then
echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
else
echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
fi
# Check CHANGELOG version matches
CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
fi
# Check composer.json version if present
if [ -f "composer.json" ]; then
COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
fi
fi
# Common checks
if [ ! -f "LICENSE" ]; then
echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
else
echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
fi
if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
else
echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
fi
# -- Platform-specific checks --------
case "$PLATFORM" in
joomla)
if [ -n "$MANIFEST" ]; then
XML_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
else
echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
fi
TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
else
echo "- No Joomla XML manifest (WaaS site)" >> $GITHUB_STEP_SUMMARY
fi ;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
MOD_VER=$(sed -n "s/.*\\\$this->version = '\([^']*\)'.*/\1/p" "$MOD_FILE" 2>/dev/null | head -1)
if [ -n "$MOD_VER" ] && [ "$MOD_VER" != "$VERSION" ]; then
echo "- Module drift: \`${MOD_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
else
echo "- Module version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
fi
else
echo "- No mod*.class.php found" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
fi
if [ ! -f "update.txt" ]; then
echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
fi ;;
*) echo "- Generic platform no manifest checks" >> $GITHUB_STEP_SUMMARY ;;
esac
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -gt 0 ]; then
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
else
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
fi
php /tmp/moko-platform-api/cli/release_validate.php \
--path . --version "$VERSION" --output-summary --github-output || true
# -- STEP 2: Create or update version/XX.YY archive branch ---------------
# Always runs — every version change on main archives to version/XX.YY
@@ -265,25 +259,7 @@ jobs:
php /tmp/moko-platform-api/cli/badge_update.php --path . --version "${VERSION}" 2>/dev/null || true
php /tmp/moko-platform-api/cli/version_check.php --path . --fix 2>/dev/null || true
- name: "Step 5: Write update stream"
if: >-
steps.version.outputs.skip != 'true' &&
steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
# Fetch latest updates.xml from main so preserve logic has all channels
GA_TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API}/contents/updates.xml?ref=main" 2>/dev/null | \
python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin)['content']).decode())" \
> updates.xml 2>/dev/null || true
php /tmp/moko-platform-api/cli/updates_xml_build.php \
--path . --version "${VERSION}" --stability stable \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
--github-output
# Step 5 (updates.xml) moved after Step 8 to include SHA-256 checksum
- name: Commit release changes
if: >-
@@ -320,256 +296,116 @@ jobs:
fi
echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
# -- STEP 7: Create or update Gitea Release --------------------------------
- name: "Step 7: Gitea Release"
# -- STEP 7a: Promote RC to stable (skip build) ----------------------------
- name: "Step 7a: Promote RC to stable"
if: >-
steps.version.outputs.skip != 'true'
steps.version.outputs.skip != 'true' &&
steps.rc.outputs.promote == 'true'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_promote.php \
--from release-candidate --to stable \
--token "${{ secrets.GA_TOKEN }}" \
--api-base "${API_BASE}" \
--path . --branch main
echo "Promoted RC → stable (${VERSION})" >> $GITHUB_STEP_SUMMARY
# -- STEP 7b: Create or update Gitea Release (full build path) -------------
- name: "Step 7b: Gitea Release"
if: >-
steps.version.outputs.skip != 'true' &&
steps.rc.outputs.promote != 'true'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
BRANCH="${{ steps.version.outputs.branch }}"
MAJOR="${{ steps.version.outputs.major }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_create.php \
--path . --version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch main
echo "Release created: ${VERSION}" >> $GITHUB_STEP_SUMMARY
# Reuse metadata from Step 5 (single source of truth)
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
EXT_NAME="${{ steps.updates.outputs.ext_name }}"
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
# Fallbacks if Step 5 was skipped
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
[ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
# Build release name: "Pretty Name VERSION (type_element-VERSION)"
# Strip existing type prefix to prevent duplication
EXT_ELEMENT=$(echo "$EXT_ELEMENT" | sed -E 's/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)//')
TYPE_PREFIX=""
case "${EXT_TYPE}" in
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
module) TYPE_PREFIX="mod_" ;;
component) TYPE_PREFIX="com_" ;;
template) TYPE_PREFIX="tpl_" ;;
library) TYPE_PREFIX="lib_" ;;
package) TYPE_PREFIX="pkg_" ;;
esac
RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
# Delete existing release if present (overwrite, not append)
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
if [ -n "$EXISTING_ID" ]; then
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true
echo "Deleted previous stable release (id: ${EXISTING_ID})"
fi
# Create fresh release
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/json" \
"${API_BASE}/releases" \
-d "$(python3 -c "import json; print(json.dumps({
'tag_name': '${RELEASE_TAG}',
'name': '${RELEASE_NAME}',
'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''',
'target_commitish': '${BRANCH}'
}))")"
echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
# -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
- name: "Step 8: Build package and update checksum"
# -- STEP 8: Build packages and upload to release ----------------------------
- name: "Step 8: Build package and upload"
id: package
if: >-
steps.version.outputs.skip != 'true'
steps.version.outputs.skip != 'true' &&
steps.rc.outputs.promote != 'true'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_package.php \
--path . --version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
# All ZIPs upload to the major release tag (vXX)
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
exit 0
# -- STEP 5: Write update stream (after build so SHA-256 is available) -----
- name: "Step 5: Write update stream"
if: steps.version.outputs.skip != 'true'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
# Fetch latest updates.xml from main so preserve logic has all channels
GA_TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API}/contents/updates.xml?ref=main" 2>/dev/null | \
python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin)['content']).decode())" \
> updates.xml 2>/dev/null || true
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php /tmp/moko-platform-api/cli/updates_xml_build.php \
--path . --version "${VERSION}" --stability stable \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG} --github-output
# Commit updates.xml if changed
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 remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add updates.xml
git commit -m "chore: update stable channel ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push origin HEAD 2>&1 || true
fi
# Find extension element name from manifest
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
[ -z "$MANIFEST" ] && exit 0
# Reuse element from Step 5, with same fallback chain
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
# ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
# For packages, prefer <packagename> over filename-derived element
if [ "$EXT_TYPE" = "package" ]; then
PKG_NAME=$(sed -n 's/.*<packagename>\([^<]*\)<\/packagename>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
[ -n "$PKG_NAME" ] && EXT_ELEMENT="$PKG_NAME"
fi
# Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
EXT_ELEMENT=$(echo "$EXT_ELEMENT" | sed -E 's/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)//')
TYPE_PREFIX=""
case "${EXT_TYPE}" in
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
module) TYPE_PREFIX="mod_" ;;
component) TYPE_PREFIX="com_" ;;
template) TYPE_PREFIX="tpl_" ;;
library) TYPE_PREFIX="lib_" ;;
package) TYPE_PREFIX="pkg_" ;;
esac
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
# -- Build install packages from src/ ----------------------------
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/"; exit 0; }
# ZIP package (type-aware via moko-platform PHP API)
php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --output /tmp
# Match the expected ZIP_NAME for upload
BUILT_ZIP=$(ls /tmp/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip 2>/dev/null | head -1 || true)
if [ -n "$BUILT_ZIP" ] && [ "$BUILT_ZIP" != "/tmp/${ZIP_NAME}" ]; then
mv "$BUILT_ZIP" "/tmp/${ZIP_NAME}"
fi
# tar.gz package (flat source archive)
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" --exclude='.ftpignore' --exclude='sftp-config*' --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
# -- Calculate SHA-256 for both ----------------------------------
SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
# -- Get existing assets for cleanup --------------------------------
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
# -- Create per-file .sha256 checksum files -------------------------
echo "${SHA256_ZIP} ${ZIP_NAME}" > "/tmp/${ZIP_NAME}.sha256"
echo "${SHA256_TAR} ${TAR_NAME}" > "/tmp/${TAR_NAME}.sha256"
# -- Upload packages + checksums to release tag --------------------
for ASSET in "${ZIP_NAME}" "${TAR_NAME}" "${ZIP_NAME}.sha256" "${TAR_NAME}.sha256"; do
[ ! -f "/tmp/${ASSET}" ] && continue
# Delete existing asset with same name
ASSET_ID=$(echo "$ASSETS" | python3 -c "
import sys,json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '${ASSET}':
print(a['id']); break
" 2>/dev/null || true)
[ -n "$ASSET_ID" ] && curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
# Upload
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${ASSET}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${ASSET}" > /dev/null 2>&1 || true
done
# updates.xml already handled by Step 5 (updates_xml_build.php with preserve logic)
echo "### Packages" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY
echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY
echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
# -- STEP 8b: Update release description with changelog ----------------------
- name: "Step 8b: Update release body"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
MOKO_CLI="/tmp/moko-platform-api/cli"
php ${MOKO_CLI}/release_body_update.php \
php /tmp/moko-platform-api/cli/release_body_update.php \
--path . --version "${VERSION}" --tag "${RELEASE_TAG}" \
--token "${{ secrets.GA_TOKEN }}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
2>/dev/null || {
# Fallback: simple body update if CLI not available
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\nChecksum files attached as \`*.sha256\` assets."
curl -sf -X PATCH -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/json" \
"${API_BASE}/releases/${RELEASE_ID}" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
fi
}
2>&1 || true
echo "Release body updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
steps.version.outputs.stability == 'stable' &&
secrets.GH_TOKEN != ''
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
MAJOR="${{ steps.version.outputs.major }}"
BRANCH="${{ steps.version.outputs.branch }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
echo "$NOTES" > /tmp/release_notes.md
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
if [ -z "$EXISTING" ]; then
gh release create "$RELEASE_TAG" \
--repo "$GH_REPO" \
--title "v${MAJOR} (latest: ${VERSION})" \
--notes-file /tmp/release_notes.md \
--target "$BRANCH" || true
else
gh release edit "$RELEASE_TAG" \
--repo "$GH_REPO" \
--title "v${MAJOR} (latest: ${VERSION})" || true
fi
# Upload assets to GitHub mirror
for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
if [ -f "$PKG" ]; then
_RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
[ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
fi
done
echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
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.GA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_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"
@@ -593,11 +429,13 @@ jobs:
- name: "Delete lesser pre-release channels"
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_cascade.php \
--stability stable \
--version "${VERSION}" \
--token "${{ secrets.GA_TOKEN }}" \
--org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
--gitea-url "${GITEA_URL}" 2>/dev/null || true
--api-base "${API_BASE}" 2>/dev/null || true
- name: "Step 11: Delete and recreate dev branch from main"
if: steps.version.outputs.skip != 'true'
@@ -620,26 +458,14 @@ jobs:
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Dolibarr: Reset dev version"
if: >-
steps.version.outputs.skip != 'true' &&
steps.platform.outputs.platform == 'dolibarr' &&
steps.platform.outputs.mod_file != ''
- 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}"
TOKEN="${{ secrets.GA_TOKEN }}"
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
ENCODED_PATH=$(echo "$MOD_FILE" | sed 's|^\./||' | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))")
FILE_RESP=$(curl -sf -H "Authorization: token ${TOKEN}" "${API_BASE}/contents/${ENCODED_PATH}?ref=dev" 2>/dev/null || true)
FILE_SHA=$(echo "$FILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
FILE_CONTENT=$(echo "$FILE_RESP" | python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin).get('content','')).decode())" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -n "$FILE_CONTENT" ]; then
UPDATED=$(echo "$FILE_CONTENT" | sed "s/\$this->version = '[^']*'/\$this->version = 'development'/")
ENCODED=$(echo "$UPDATED" | base64 -w0)
curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/contents/${ENCODED_PATH}" \
-d "$(jq -n --arg content \"$ENCODED\" --arg sha \"$FILE_SHA\" --arg msg \"chore(version): reset dev version [skip ci]\" --arg branch \"dev\" '{content:$content,sha:$sha,message:$msg,branch:$branch}')" > /dev/null 2>&1 || true
fi
php /tmp/moko-platform-api/cli/version_reset_dev.php \
--token "${{ secrets.GA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
-139
View File
@@ -1,139 +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.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# 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 moko-platform 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}/moko-platform.git" \
/tmp/moko-platform-api 2>/dev/null || true
if [ -d "/tmp/moko-platform-api" ] && [ -f "/tmp/moko-platform-api/composer.json" ]; then
cd /tmp/moko-platform-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/moko-platform-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/moko-platform-api/deploy/deploy-joomla.php" ]; then
php /tmp/moko-platform-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/moko-platform-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Post-deploy health check
if: success() && steps.check.outputs.skip != 'true'
run: |
if [ -f "deploy/health-check.php" ]; then
SITE_URL="${{ vars.DEV_SITE_URL }}"
if [ -n "$SITE_URL" ]; then
php deploy/health-check.php --url "$SITE_URL" --checks http --timeout 30 || echo "::warning::Health check failed after deploy"
else
echo "DEV_SITE_URL not configured, skipping health check"
fi
fi
- 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
+60 -212
View File
@@ -13,6 +13,10 @@
name: "Universal: Pre-Release"
on:
pull_request:
types: [closed]
branches:
- dev
workflow_dispatch:
inputs:
stability:
@@ -35,8 +39,11 @@ env:
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability }})"
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
steps:
- name: Checkout
@@ -45,46 +52,29 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
- name: Setup tools
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
# Update moko-platform CLI tools if available; install PHP if missing
if command -v moko-platform-update &> /dev/null; then
moko-platform-update
elif [ -d "/opt/moko-platform" ]; then
cd /opt/moko-platform && git pull origin main --quiet 2>/dev/null || true
else
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
fi
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/moko-platform-api
fi
# Set MOKO_CLI to whichever path exists
if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/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: |
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve metadata and bump version
id: meta
run: |
STABILITY="${{ inputs.stability }}"
STABILITY="${{ inputs.stability || 'development' }}"
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
@@ -93,20 +83,16 @@ jobs:
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Patch bump via CLI tool
php ${MOKO_CLI}/version_bump.php --path .
# 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"
TODAY=$(date +%Y-%m-%d)
# Update platform-specific manifest
PLATFORM="${{ steps.platform.outputs.platform }}"
MANIFEST="${{ steps.platform.outputs.manifest }}"
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
# Verify version consistency across all files
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
@@ -117,36 +103,16 @@ jobs:
git push origin HEAD 2>&1
}
# Auto-detect element (platform-aware)
EXT_ELEMENT=""
case "$PLATFORM" in
joomla)
if [ -n "$MANIFEST" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
*)
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
;;
esac
# Auto-detect element via manifest_element.php
php ${MOKO_CLI}/manifest_element.php \
--path . --version "$VERSION" --stability "$STABILITY" \
--repo "${GITEA_REPO}" --github-output
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
# Read back element outputs
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
@@ -154,168 +120,50 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Build package
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::error::No src/ or htdocs/ directory"
exit 1
fi
MANIFEST="${{ steps.meta.outputs.manifest }}"
EXT_TYPE=""
if [ -n "$MANIFEST" ]; then
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
fi
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
mkdir -p build/package
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
echo "=== Building Joomla PACKAGE (multi-extension) ==="
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
[ ! -d "$ext_dir" ] && continue
EXT_NAME=$(basename "$ext_dir")
echo " Packaging sub-extension: ${EXT_NAME}"
cd "$ext_dir"
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
cd "$OLDPWD"
done
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
[ -f "$f" ] && cp "$f" build/package/
done
else
echo "=== Building standard extension ==="
rsync -a \
--exclude='sftp-config*' \
--exclude='.ftpignore' \
--exclude='*.ppk' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='.env*' \
--exclude='*.local' \
--exclude='.build-trigger' \
"${SOURCE_DIR}/" build/package/
fi
- name: Create ZIP
id: zip
run: |
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
cd build/package
zip -r "../${ZIP_NAME}" .
cd ..
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
- name: Create or replace Gitea release
- name: Create release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
BRANCH=$(git branch --show-current)
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease
BODY="## ${VERSION} ($(date +%Y-%m-%d))
**Channel:** ${STABILITY}
**SHA-256:** \`${SHA256}\`"
# Delete existing release
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/tags/${TAG}" 2>/dev/null || true
fi
# Create release
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/releases" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg target "$BRANCH" \
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
--arg body "$BODY" \
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
)" | jq -r '.id')
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
# Upload ZIP
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
--data-binary "@build/${ZIP_NAME}"
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
- name: Build package and upload
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.GA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
STABILITY="${{ steps.meta.outputs.stability }}"
VERSION="${{ steps.meta.outputs.version }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
TAG="${{ steps.meta.outputs.tag }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml -- skipping"
exit 0
fi
# Map stability to XML tag name
case "$STABILITY" in
development) XML_TAG="development" ;;
alpha) XML_TAG="alpha" ;;
beta) XML_TAG="beta" ;;
release-candidate) XML_TAG="rc" ;;
*) XML_TAG="$STABILITY" ;;
esac
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${TAG}/${ZIP_NAME}"
# Use PHP to update the channel in updates.xml
php -r '
$xml_tag = $argv[1];
$version = $argv[2];
$sha256 = $argv[3];
$url = $argv[4];
$date = date("Y-m-d");
$content = file_get_contents("updates.xml");
$pattern = "/(<update>(?:(?!<\/update>).)*?<tag>" . preg_quote($xml_tag) . "<\/tag>.*?<\/update>)/s";
$content = preg_replace_callback($pattern, function($m) use ($version, $sha256, $url, $date) {
$block = $m[0];
$block = preg_replace("/<version>[^<]*<\/version>/", "<version>{$version}</version>", $block);
if (strpos($block, "<sha256>") !== false) {
$block = preg_replace("/<sha256>[^<]*<\/sha256>/", "<sha256>{$sha256}</sha256>", $block);
} else {
$block = str_replace("</downloads>", "</downloads>\n <sha256>{$sha256}</sha256>", $block);
}
$block = preg_replace("/(<downloadurl[^>]*>)[^<]*(<\/downloadurl>)/", "\${1}{$url}\${2}", $block);
return $block;
}, $content);
file_put_contents("updates.xml", $content);
echo "Updated {$xml_tag} channel: version={$version}\n";
' "$XML_TAG" "$VERSION" "$SHA256" "$DOWNLOAD_URL"
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
@@ -337,7 +185,7 @@ jobs:
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml -> ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
git checkout "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
@@ -364,7 +212,7 @@ jobs:
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
+605
View File
@@ -0,0 +1,605 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/update-server.yml
# VERSION: 04.07.00
# BRIEF: Update server XML feed with stable/rc/beta/alpha/dev entries (universal)
#
# Writes updates.xml with multiple <update> entries:
# - <tag>stable</tag> on push to main (from auto-release)
# - <tag>rc</tag> on push to rc/**
# - <tag>development</tag> on push to dev or dev/**
#
# Joomla filters by user's "Minimum Stability" setting.
name: "Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update updates.xml
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GA_TOKEN }}
fetch-depth: 0
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/tmp/moko-platform" ]; then
echo "moko-platform already available — skipping clone"
else
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
fi
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
- name: Generate updates.xml entry
id: update
run: |
BRANCH="${{ github.ref_name }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
VERSION=$(php /tmp/moko-platform/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Auto-bump patch on all branches (dev, alpha, beta, rc)
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
BUMPED=$(php /tmp/moko-platform/cli/version_bump.php --path . 2>/dev/null || true)
if [ -n "$BUMPED" ]; then
VERSION=$(php /tmp/moko-platform/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
git add -A
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
git push 2>/dev/null || true
fi
# Determine stability from branch or input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
STABILITY="development"
else
STABILITY="stable"
fi
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
# Parse manifest (portable — no grep -P)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "No Joomla manifest found — skipping"
exit 0
fi
# Extract fields using sed (works on all runners)
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
# Fallbacks
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
# Derive element if not in manifest: try XML filename, then repo name
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
# Use manifest version if README version is empty
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
# Joomla requires <client> on ALL extension types for update matching
if [ -n "$EXT_CLIENT" ]; then
CLIENT_TAG="<client>${EXT_CLIENT}</client>"
else
CLIENT_TAG="<client>site</client>"
fi
FOLDER_TAG=""
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
PHP_TAG=""
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
# Version suffix for non-stable
DISPLAY_VERSION="$VERSION"
case "$STABILITY" in
development) DISPLAY_VERSION="${VERSION}-dev" ;;
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
esac
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
# Each stability level has its own release tag
case "$STABILITY" in
development) RELEASE_TAG="development" ;;
alpha) RELEASE_TAG="alpha" ;;
beta) RELEASE_TAG="beta" ;;
rc) RELEASE_TAG="release-candidate" ;;
*) RELEASE_TAG="v${MAJOR}" ;;
esac
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
# -- Build install packages (ZIP + tar.gz) --------------------
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ -d "$SOURCE_DIR" ]; then
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
cd "$SOURCE_DIR"
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
cd ..
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
--exclude='.ftpignore' --exclude='sftp-config*' \
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
# Ensure release exists on Gitea
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
# Create release
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/json" \
"${API_BASE}/releases" \
-d "$(python3 -c "import json; print(json.dumps({
'tag_name': '${RELEASE_TAG}',
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
'body': '${STABILITY} release',
'prerelease': True,
'target_commitish': 'main'
}))")" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
fi
if [ -n "$RELEASE_ID" ]; then
# Delete existing assets with same name before uploading
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
ASSET_ID=$(echo "$ASSETS" | python3 -c "
import sys,json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '${ASSET_FILE}':
print(a['id']); break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
fi
done
# Upload both formats
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${PACKAGE_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${TAR_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
fi
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
else
SHA256=""
fi
# -- Build the new entry (canonical format matching release.yml) --
NEW_ENTRY=""
NEW_ENTRY="${NEW_ENTRY} <update>\n"
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
NEW_ENTRY="${NEW_ENTRY} </update>"
# -- Write new entry to temp file --------------------------------
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
# -- Merge into updates.xml ----------------------------------------
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
TARGETS=""
for entry in $CASCADE_MAP; do
key="${entry%%:*}"
vals="${entry#*:}"
if [ "$key" = "${STABILITY}" ]; then
TARGETS="$vals"
break
fi
done
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
echo "Cascade: ${STABILITY} → ${TARGETS}"
# Create updates.xml if missing
if [ ! -f "updates.xml" ]; then
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
printf '%s\n' "<updates>" >> updates.xml
printf '%s\n' "</updates>" >> updates.xml
fi
# Update existing blocks or create missing ones
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
python3 << 'PYEOF'
import re, os
targets = os.environ["PY_TARGETS"].split(",")
version = os.environ["PY_VERSION"]
date = os.environ["PY_DATE"]
with open("updates.xml") as f:
content = f.read()
with open("/tmp/new_entry.xml") as f:
new_entry_template = f.read()
for tag in targets:
tag = tag.strip()
# Build entry with this tag's name
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
# Try to find existing block (handles both single-line and multi-line <tags>)
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
match = re.search(block_pattern, content, re.DOTALL)
if match:
# Update in place — replace entire block
content = content.replace(match.group(1), new_entry.strip())
print(f" UPDATED: <tag>{tag}</tag> → {version}")
else:
# Create — insert before </updates>
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
print(f" CREATED: <tag>{tag}</tag> → {version}")
# Clean up excessive blank lines
content = re.sub(r"\n{3,}", "\n\n", content)
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
# -- Sync updates.xml to main (for non-main branches) ----------------------
- name: Sync updates.xml to main
if: github.ref_name != 'main'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GA_TOKEN="${{ secrets.GA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
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 ${STABILITY} [skip ci]',
'branch': 'main'
}).encode()
req = urllib.request.Request(
'${API_BASE}/contents/updates.xml',
data=payload, method='PUT',
headers={
'Authorization': 'token ${GA_TOKEN}',
'Content-Type': 'application/json'
})
try:
urllib.request.urlopen(req)
print('updates.xml synced to main')
except Exception as e:
print(f'ERROR: failed to sync updates.xml to main: {e}', file=sys.stderr)
sys.exit(1)
" \
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|| echo "::error::failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
else
echo "::error::could not get updates.xml SHA from main — file may not exist on main yet" >> $GITHUB_STEP_SUMMARY
fi
- name: Validate updates.xml integrity
run: |
ERRORS=0
if [ ! -f "updates.xml" ]; then
echo "::error::updates.xml not found"
exit 1
fi
# Well-formed XML
if ! python3 -c "import xml.etree.ElementTree as ET; ET.parse('updates.xml')" 2>/dev/null; then
echo "::error::updates.xml is not valid XML"
ERRORS=$((ERRORS+1))
fi
python3 << 'PYEOF'
import xml.etree.ElementTree as ET, sys, re, os
tree = ET.parse("updates.xml")
root = tree.getroot()
updates = root.findall("update")
errors = 0
warnings = 0
seen_tags = set()
# All 5 channels MUST be present
REQUIRED_CHANNELS = {"stable", "rc", "beta", "alpha", "dev"}
VALID_TAGS = REQUIRED_CHANNELS | {"development"} # accept legacy alias
REPO = os.environ.get("GITEA_REPO", "")
ORG = os.environ.get("GITEA_ORG", "MokoConsulting")
REPO_BASE = f"https://git.mokoconsulting.tech/{ORG}/"
# Gitea release tag names per channel (Moko standard)
RELEASE_TAG_MAP = {
"stable": "stable",
"rc": "release-candidate",
"beta": "beta",
"alpha": "alpha",
"dev": "development",
"development": "development",
}
# Joomla update XML required fields per
# https://docs.joomla.org/Deploying_an_Update_Server
REQUIRED_FIELDS = ["name", "element", "type", "version", "infourl"]
for i, u in enumerate(updates):
tag_el = u.find("tags/tag")
tag = tag_el.text.strip() if tag_el is not None and tag_el.text else None
label = f"Entry {i+1} (<tag>{tag or '?'}</tag>)"
# -- Required Joomla fields --
for field in REQUIRED_FIELDS:
el = u.find(field)
if el is None or not (el.text or "").strip():
print(f"::error::{label}: missing required <{field}>")
errors += 1
# -- <downloads><downloadurl> --
dl = u.find("downloads/downloadurl")
if dl is None or not (dl.text or "").strip():
print(f"::error::{label}: missing <downloads><downloadurl>")
errors += 1
else:
dl_url = dl.text.strip()
# Must point to org repo
if REPO_BASE not in dl_url:
print(f"::error::{label}: download URL not under {REPO_BASE}: {dl_url}")
errors += 1
# Must end in .zip
if not dl_url.endswith(".zip"):
print(f"::error::{label}: download URL must end in .zip: {dl_url}")
errors += 1
# Must use correct Gitea release tag in path
if tag and tag in RELEASE_TAG_MAP:
expected_tag = RELEASE_TAG_MAP[tag]
if f"/download/{expected_tag}/" not in dl_url:
print(f"::error::{label}: download URL should contain /download/{expected_tag}/ but got: {dl_url}")
errors += 1
# -- <client> (required for Joomla to match update) --
client = u.find("client")
if client is None or not (client.text or "").strip():
print(f"::error::{label}: missing <client> (required for Joomla update matching)")
errors += 1
# -- <targetplatform> --
tp = u.find("targetplatform")
if tp is None:
print(f"::error::{label}: missing <targetplatform>")
errors += 1
else:
tp_name = tp.get("name", "")
tp_ver = tp.get("version", "")
if tp_name != "joomla":
print(f"::error::{label}: targetplatform name should be 'joomla', got '{tp_name}'")
errors += 1
if not tp_ver:
print(f"::error::{label}: targetplatform missing version regex")
errors += 1
elif "5" not in tp_ver or "6" not in tp_ver:
print(f"::warning::{label}: targetplatform version may not cover Joomla 5+6: {tp_ver}")
warnings += 1
# -- <type> must be valid Joomla type --
type_el = u.find("type")
if type_el is not None and type_el.text:
valid_types = {"component", "module", "plugin", "template", "library", "package", "file"}
if type_el.text.strip() not in valid_types:
print(f"::error::{label}: invalid type '{type_el.text}' (expected: {valid_types})")
errors += 1
# -- <version> format (XX.YY.ZZ with optional suffix) --
ver_el = u.find("version")
if ver_el is not None and ver_el.text:
if not re.match(r"^\d{2}\.\d{2}\.\d{2}(-\w+)?$", ver_el.text.strip()):
print(f"::warning::{label}: version '{ver_el.text}' does not match XX.YY.ZZ format")
warnings += 1
# -- <maintainer> and <maintainerurl> --
for field in ["maintainer", "maintainerurl"]:
el = u.find(field)
if el is None or not (el.text or "").strip():
print(f"::warning::{label}: missing <{field}>")
warnings += 1
# -- Valid stability tag --
if tag is None:
print(f"::error::{label}: missing <tags><tag>")
errors += 1
elif tag not in VALID_TAGS:
print(f"::error::{label}: invalid tag '{tag}' (expected: {VALID_TAGS})")
errors += 1
# -- Duplicate tag check --
norm_tag = "dev" if tag == "development" else tag
if norm_tag in seen_tags:
print(f"::error::{label}: duplicate channel '{tag}'")
errors += 1
if norm_tag:
seen_tags.add(norm_tag)
# -- All 5 channels must exist --
missing = REQUIRED_CHANNELS - seen_tags
if missing:
print(f"::error::Missing required update channels: {', '.join(sorted(missing))}")
errors += 1
# -- Version ordering: higher stability must not exceed dev version --
channel_versions = {}
for u in updates:
tag_el = u.find("tags/tag")
ver_el = u.find("version")
if tag_el is not None and ver_el is not None and tag_el.text and ver_el.text:
norm = "dev" if tag_el.text.strip() == "development" else tag_el.text.strip()
# Strip suffix for comparison (01.00.18-dev -> 01.00.18)
base_ver = re.sub(r"-\w+$", "", ver_el.text.strip())
channel_versions[norm] = base_ver
# Cascade check: dev >= alpha >= beta >= rc >= stable
ORDER = ["dev", "alpha", "beta", "rc", "stable"]
for j in range(1, len(ORDER)):
current = ORDER[j]
previous = ORDER[j - 1]
if current in channel_versions and previous in channel_versions:
if channel_versions[current] > channel_versions[previous]:
print(f"::error::{current} version ({channel_versions[current]}) is ahead of {previous} ({channel_versions[previous]})")
errors += 1
# -- Summary --
print(f"\nupdates.xml validation: {len(updates)} entries, {errors} error(s), {warnings} warning(s)")
if errors > 0:
sys.exit(1)
PYEOF
- name: Summary
if: always()
run: |
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
+34
View File
@@ -18,6 +18,40 @@ Version format: `XX.YY.ZZ` (zero-padded semver).
## [Unreleased]
### Added
- `auto-bump.yml`: auto patch-bump version on every push to dev
- Removed redundant bump from pre-release.yml (handled by auto-bump)
## [09.02.00] - 2026-05-26
### Added
- **Release promotion pipeline**: draft PR → RC promotion, merged PR → RC-to-stable (skip rebuild)
- **7 new CLI tools**: `manifest_element.php`, `release_create.php`, `release_package.php`, `release_promote.php`, `release_mirror.php`, `version_reset_dev.php`, `ManifestReader.php`
- `version_bump.php` / `version_read.php`: support for `package.json` (Node.js) and `pyproject.toml` (Python)
- `version_bump.php`: now writes bumped version to all sources (README, manifests, Dolibarr mod, composer.json, package.json, pyproject.toml)
- `release_cascade.php`: `--version` flag for version-aware deletion of stale releases
- `release_validate.php`: auto-detect platform from manifest.xml, `--github-output` flag, source dir check
- `updates_xml_build.php`: supports non-Joomla platforms via manifest.xml detection
- `release_package.php`: reads entry-point from manifest.xml for source dir resolution
- `auto-release.yml`: `workflow_dispatch` with `promote-rc` action as fallback for MokoGitea#220
- `update-server.yml`: now universal — pushed to all 69+ repos (Joomla, Dolibarr, generic, MCP)
- `ManifestReader.php`: shared typed accessor for `.mokogitea/manifest.xml`
- Universal workflow cascade: Template-Generic → other templates → all repos via `bulk_sync.php`
- Wiki: UPDATE_SERVER standard page on moko-platform and all template repos
- PHPDoc added to 4 classes missing class-level docs
### Changed
- `auto-release.yml`: 761 → 490 lines — replaced all inline bash with CLI tool calls
- `pre-release.yml`: 389 → 314 lines — replaced inline logic with `manifest_read.php`, `manifest_element.php`, `updates_xml_build.php`
- Removed `paths` filter from workflow triggers (enables Go, Node.js, generic repo compatibility)
- `RepositorySynchronizer.php`: fixed template repo names, `.mokogitea/workflows` path, universal workflow sync
- Template-Generic is now the single source of truth for universal workflows
### Fixed
- `release_cascade.php` in `auto-release.yml`: was using `--org`/`--repo` flags instead of `--api-base`
- `pre-release.yml`: updates.xml sync was checking out entire branch tree instead of just `updates.xml`
- MokoWaaS#48: Joomla 6 typed event API fix for `plg_webservices_mokowaas`
## [09.00.00] - 2026-05-26
### Added
+1 -1
View File
@@ -11,7 +11,7 @@ This file provides guidance to Claude Code when working with this repository.
| **Language** | PHP 8.1+ |
| **Default branch** | main |
| **License** | GPL-3.0-or-later |
| **Version** | 06.00.00 |
| **Version** | 09.01.00 |
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
## Common Commands
+3
View File
@@ -6,11 +6,14 @@ DEFGROUP: MokoStandards.Root
INGROUP: MokoStandards
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
PATH: /README.md
VERSION: 09.02.04
BRIEF: Project overview and documentation
-->
# MokoStandards 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.
> **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API)
+5
View File
@@ -156,6 +156,11 @@ class BulkSync extends CliFramework
return 0;
}
// Sync universal workflows from Template-Generic → other templates first
$this->log("📋 Syncing universal workflows to template repos...", 'INFO');
$templateUpdates = $this->synchronizer->syncUniversalWorkflowsToTemplates($org);
$this->log("Template sync: {$templateUpdates} file(s) updated", 'INFO');
// Execute synchronization
$this->log("🔄 Starting synchronization...", 'INFO');
$results = $this->executeSynchronization($org, $repositories, $alreadyProcessed);
+8
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -123,14 +124,21 @@ 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',
// Version management
'version:read' => 'cli/version_read.php',
'version:bump' => 'cli/version_bump.php',
'version:check' => 'cli/version_check.php',
'version:propagate' => 'maintenance/update_version_from_readme.php',
'version:set-platform' => 'cli/version_set_platform.php',
'version:reset-dev' => 'cli/version_reset_dev.php',
// Build & package
'build:package' => 'cli/package_build.php',
+8
View File
@@ -26,6 +26,14 @@ require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
/**
* Joomla Release Manager
*
* Creates and manages Joomla extension releases on Gitea, including
* package building, asset upload, and update stream management.
*
* @since 04.06.00
*/
class JoomlaRelease extends CliFramework
{
private const VERSION = '04.06.00';
+235
View File
@@ -0,0 +1,235 @@
#!/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
* 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 = '';
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 === '--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>, plugin= attribute, <packagename>, or filename
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[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)));
}
}
// Human-readable name
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
$extName = trim($nm[1]);
}
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];
}
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";
}
}
} else {
foreach ($outputs as $key => $value) {
echo "{$key}={$value}\n";
}
}
exit(0);
+1
View File
@@ -103,6 +103,7 @@ if ($xml === false) {
'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 ?? ''),
+147 -56
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -14,12 +15,16 @@
* Usage:
* php release_cascade.php --stability stable --token TOKEN --api-base URL
* php release_cascade.php --stability rc --token TOKEN --api-base URL
* php release_cascade.php --stability stable --version 09.01.00 --token TOKEN --api-base URL
*
* Cascade rules:
* stable -> deletes development, alpha, beta, release-candidate
* rc -> deletes development, alpha, beta
* beta -> deletes development, alpha
* alpha -> deletes development
*
* When --version is given, also deletes releases on any channel whose version
* is lower than the specified version (prevents stale pre-releases lingering).
*/
declare(strict_types=1);
@@ -27,90 +32,176 @@ declare(strict_types=1);
$stability = null;
$token = null;
$apiBase = null;
$version = null;
foreach ($argv as $i => $arg) {
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $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 === '--stability' && isset($argv[$i + 1])) {
$stability = $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 === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
}
// Allow token from environment
if ($token === null) {
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
}
if ($stability === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: release_cascade.php --stability [stable|rc|beta|alpha] --token TOKEN --api-base URL\n");
fwrite(STDERR, " --api-base: e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo\n");
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
exit(1);
fwrite(STDERR, "Usage: release_cascade.php --stability [stable|rc|beta|alpha] --token TOKEN --api-base URL\n");
fwrite(STDERR, " --api-base: e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo\n");
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
exit(1);
}
// Define cascade hierarchy
$cascadeMap = [
'stable' => ['development', 'alpha', 'beta', 'release-candidate'],
'release-candidate' => ['development', 'alpha', 'beta'],
'rc' => ['development', 'alpha', 'beta'],
'beta' => ['development', 'alpha'],
'alpha' => ['development'],
'stable' => ['development', 'alpha', 'beta', 'release-candidate'],
'release-candidate' => ['development', 'alpha', 'beta'],
'rc' => ['development', 'alpha', 'beta'],
'beta' => ['development', 'alpha'],
'alpha' => ['development'],
];
if (!isset($cascadeMap[$stability])) {
fwrite(STDERR, "Unknown stability level: {$stability}\n");
fwrite(STDERR, "Valid options: stable, rc, beta, alpha\n");
exit(1);
fwrite(STDERR, "Unknown stability level: {$stability}\n");
fwrite(STDERR, "Valid options: stable, rc, beta, alpha\n");
exit(1);
}
$tagsToDelete = $cascadeMap[$stability];
$deleted = 0;
foreach ($tagsToDelete as $tag) {
// Get release by tag
$ch = curl_init("{$apiBase}/releases/tags/{$tag}");
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);
// Get release by tag
$ch = curl_init("{$apiBase}/releases/tags/{$tag}");
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)) {
continue;
}
if ($httpCode !== 200 || empty($response)) {
continue;
}
$data = json_decode($response, true);
$releaseId = $data['id'] ?? null;
$data = json_decode($response, true);
$releaseId = $data['id'] ?? null;
if ($releaseId === null) {
continue;
}
if ($releaseId === null) {
continue;
}
// Delete release
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ch);
curl_close($ch);
// Delete release
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ch);
curl_close($ch);
// Delete tag
$ch = curl_init("{$apiBase}/tags/{$tag}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ch);
curl_close($ch);
// Delete tag
$ch = curl_init("{$apiBase}/tags/{$tag}");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ch);
curl_close($ch);
echo "Deleted: {$tag} (release id: {$releaseId})\n";
$deleted++;
echo "Deleted: {$tag} (release id: {$releaseId})\n";
$deleted++;
}
// ── Version-aware cleanup: delete releases with lesser version numbers ───────
if ($version !== null) {
// Normalize version for comparison (strip any suffix)
$baseVersion = preg_replace('/-[a-z]+$/', '', $version);
// Check all channels (including ones not in the cascade map for this stability)
$allChannels = ['development', 'alpha', 'beta', 'release-candidate', 'stable'];
foreach ($allChannels as $tag) {
// Skip the current stability channel
if ($tag === $stability) {
continue;
}
// Skip channels already deleted by cascade above
if (in_array($tag, $tagsToDelete, true)) {
continue;
}
$ch = curl_init("{$apiBase}/releases/tags/{$tag}");
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)) {
continue;
}
$data = json_decode($response, true);
$releaseId = $data['id'] ?? null;
$releaseName = $data['name'] ?? '';
if ($releaseId === null) {
continue;
}
// Extract version from release name (e.g. "element 09.00.01 (development)")
$releaseVersion = null;
if (preg_match('/(\d{2}\.\d{2}\.\d{2})/', $releaseName, $vm)) {
$releaseVersion = $vm[1];
}
if ($releaseVersion === null) {
continue;
}
// Delete if release version is less than the promoted version
if (version_compare($releaseVersion, $baseVersion, '<')) {
$delCh = curl_init("{$apiBase}/releases/{$releaseId}");
curl_setopt_array($delCh, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($delCh);
curl_close($delCh);
$tagCh = curl_init("{$apiBase}/tags/{$tag}");
curl_setopt_array($tagCh, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($tagCh);
curl_close($tagCh);
echo "Deleted: {$tag} — version {$releaseVersion} < {$baseVersion}\n";
$deleted++;
}
}
}
echo "Cleaned up {$deleted} pre-release channel(s)\n";
+328
View File
@@ -0,0 +1,328 @@
#!/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
* 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 ────────────────────────────────────────────────────────
$path = '.';
$version = null;
$tag = null;
$token = null;
$apiBase = null;
$branch = 'main';
$repoName = '';
$prerelease = false;
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('GA_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 GA_TOKEN or GITEA_TOKEN env var\n");
exit(1);
}
// ── Helper: Gitea API request ───────────────────────────────────────────────
/**
* Send a request to the Gitea API.
*
* @param string $url Full API URL
* @param string $token Authorization token
* @param string $method HTTP method (GET, POST, DELETE, etc.)
* @param string|null $body JSON request body
*
* @return array<string, mixed>|null Decoded response or null on failure
*/
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url);
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;
}
// ── Detect element metadata ─────────────────────────────────────────────────
$root = realpath($path) ?: $path;
$extElement = '';
$extType = '';
$extFolder = '';
$extName = '';
$typePrefix = '';
// Detect platform from manifest.xml
$platform = 'generic';
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml);
if ($content !== false && 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 ($c !== false && strpos($c, '<extension') !== false) {
$extManifest = $file;
break;
}
}
// Find Dolibarr module file
$modFile = null;
$modFiles = array_merge(
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($modFiles as $file) {
$c = file_get_contents($file);
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
$modFile = $file;
break;
}
}
// Extract metadata based on platform
switch (true) {
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest);
if ($xml === false) {
break;
}
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
// Element name: <element>, plugin= attribute, <packagename>, or filename
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
$extElement = $pm2[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1];
}
if (empty($extElement)) {
$extElement = strtolower(basename($extManifest, '.xml'));
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
}
// Human-readable name
if (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 ──────────────────────────────────────────────────────
$releaseName = "{$extName} {$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";
}
}
}
// ── 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);
+300
View File
@@ -0,0 +1,300 @@
#!/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
* 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_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 ─────────────────────────────────────────────────────────
$version = null;
$tag = null;
$token = null;
$apiBase = null;
$ghToken = null;
$ghRepo = null;
$branch = 'main';
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('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
$ghToken = $ghToken ?: (getenv('GH_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_TOKEN --gh-repo org/repo [--branch main]\n");
fwrite(STDERR, " --token: Gitea token (or GA_TOKEN / GITEA_TOKEN env)\n");
fwrite(STDERR, " --gh-token: GitHub token (or GH_TOKEN env)\n");
exit(1);
}
// ── Helper: Gitea API request ────────────────────────────────────────────────
/**
* Send a request to the Gitea API.
*
* @param string $url Full Gitea API URL
* @param string $token Gitea API token
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
* @param string|null $body JSON request body or null
*
* @return array<string, mixed>|null Decoded response or null on failure
*/
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url);
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;
}
$localPath = "{$tmpDir}/{$name}";
echo " Downloading: {$name}\n";
if (!giteaDownload($downloadUrl, $token, $localPath)) {
fwrite(STDERR, " Failed to download: {$name}\n");
continue;
}
// ── 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";
}
// ── 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);
+567
View File
@@ -0,0 +1,567 @@
#!/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
* PATH: /cli/release_package.php
* BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release
*
* Usage:
* php release_package.php --path . --version 09.01.00 --tag stable --token TOKEN --api-base URL
* php release_package.php --path . --version 09.01.00 --tag development --token TOKEN --api-base URL --repo myrepo
*
* Builds ZIP and tar.gz packages from src/ or htdocs/, computes SHA-256 checksums,
* creates .sha256 sidecar files, and uploads all assets to an existing Gitea release.
*
* For Joomla packages (type=package with packages/ subdir):
* - ZIPs each sub-extension directory
* - Copies top-level XML/PHP to package root before archiving
*
* For standard extensions:
* - Builds ZIP and tar.gz from source dir
* - Excludes: sftp-config*, .ftpignore, *.ppk, *.pem, *.key, .env*, *.local, .build-trigger
*/
declare(strict_types=1);
// ── Argument parsing ─────────────────────────────────────────────────────────
$path = '.';
$version = null;
$tag = null;
$token = null;
$apiBase = null;
$repoName = '';
$outputDir = sys_get_temp_dir();
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 === '--repo' && isset($argv[$i + 1])) {
$repoName = $argv[$i + 1];
}
if ($arg === '--output' && isset($argv[$i + 1])) {
$outputDir = $argv[$i + 1];
}
}
// Allow token from environment
if ($token === null) {
$token = getenv('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null);
}
if ($version === null || $tag === null || $token === null || $apiBase === null) {
fwrite(STDERR, "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL\n");
fwrite(STDERR, " --repo REPO Repo name for element detection fallback\n");
fwrite(STDERR, " --output DIR Output directory for built packages (default: sys_get_temp_dir())\n");
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
exit(1);
}
$root = realpath($path) ?: $path;
// ── Helper: Gitea API request ────────────────────────────────────────────────
/**
* Perform a Gitea API request.
*
* @param string $url Full API URL
* @param string $token API token
* @param string $method HTTP method
* @param string|null $body Request body (JSON)
*
* @return array{data: array<string, mixed>|null, code: int}
*/
function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array
{
$ch = curl_init($url);
if ($ch === false) {
return ['data' => null, 'code' => 0];
}
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 || !is_string($response) || $response === '') {
return ['data' => null, 'code' => $httpCode];
}
$decoded = json_decode($response, true);
return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode];
}
/**
* Upload a file as a release asset.
*
* @param string $url Upload endpoint URL
* @param string $token API token
* @param string $filePath Local file path
*
* @return int HTTP status code
*/
function giteaUploadAsset(string $url, string $token, string $filePath): int
{
$ch = curl_init($url);
if ($ch === false) {
return 0;
}
$fileContent = file_get_contents($filePath);
if ($fileContent === false) {
return 0;
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/octet-stream',
],
CURLOPT_POSTFIELDS => $fileContent,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode;
}
// ── Read platform from .mokogitea/manifest.xml ───────────────────────────────
$detectedPlatform = 'generic';
$detectedEntryPoint = '';
$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,
};
}
$detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? '');
}
}
// ── Detect element metadata from manifest XML ────────────────────────────────
$extElement = '';
$extType = '';
$extFolder = '';
$typePrefix = '';
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
$extManifest = null;
foreach ($manifestFiles as $file) {
$content = file_get_contents($file);
if ($content !== false && strpos($content, '<extension') !== false) {
$extManifest = $file;
break;
}
}
if ($extManifest !== null) {
$xml = file_get_contents($extManifest);
if ($xml === false) {
$xml = '';
}
// 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>, plugin= attribute, <packagename>, or filename
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
$extElement = $pm[1];
}
// For packages: prefer <packagename> over filename
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
$extElement = $pn[1];
}
if ($extElement === '') {
$extElement = strtolower(basename($extManifest, '.xml'));
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
}
}
// Fallback to repo name
if ($extElement === '') {
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
}
// Strip existing type prefix to prevent duplication
$extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $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;
}
echo "Element: {$typePrefix}{$extElement}\n";
echo "Type: {$extType}\n";
// ── Compute filenames ────────────────────────────────────────────────────────
$baseName = "{$typePrefix}{$extElement}-{$version}";
$zipFile = "{$outputDir}/{$baseName}.zip";
$tarFile = "{$outputDir}/{$baseName}.tar.gz";
echo "ZIP: {$baseName}.zip\n";
echo "TAR: {$baseName}.tar.gz\n";
// ── Find source directory ────────────────────────────────────────────────────
$sourceDir = null;
// Use entry-point from manifest.xml if available
if ($detectedEntryPoint !== '') {
$entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/');
if (is_dir("{$root}/{$entryDir}")) {
$sourceDir = "{$root}/{$entryDir}";
}
}
// Fallback to common directories
if ($sourceDir === null && is_dir("{$root}/src")) {
$sourceDir = "{$root}/src";
} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) {
$sourceDir = "{$root}/htdocs";
}
if ($sourceDir === null) {
echo "No src/ or htdocs/ directory found — skipping package build\n";
exit(0);
}
echo "Source: {$sourceDir}\n";
// ── File exclusion patterns ──────────────────────────────────────────────────
/** @var array<int, string> */
$excludePatterns = [
'sftp-config*',
'.ftpignore',
'*.ppk',
'*.pem',
'*.key',
'.env*',
'*.local',
'.build-trigger',
];
/**
* Check if a filename matches any exclusion pattern.
*
* @param string $filename Filename to check
* @param array<int,string> $patterns Glob patterns to exclude
*
* @return bool True if the file should be excluded
*/
function isExcluded(string $filename, array $patterns): bool
{
$basename = basename($filename);
foreach ($patterns as $pattern) {
if (fnmatch($pattern, $basename)) {
return true;
}
}
return false;
}
/**
* Recursively add files from a directory to a ZipArchive.
*
* @param ZipArchive $zip ZipArchive instance
* @param string $sourceDir Source directory path
* @param string $prefix Path prefix inside the archive
* @param array<int,string> $excludes Exclusion patterns
*/
function addDirToZip(ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if (!$file instanceof SplFileInfo || !$file->isFile()) {
continue;
}
$realPath = $file->getRealPath();
if ($realPath === false) {
continue;
}
if (isExcluded($file->getFilename(), $excludes)) {
continue;
}
$relativePath = substr($realPath, strlen($sourceDir) + 1);
// Normalise to forward slashes for ZIP compatibility
$relativePath = str_replace('\\', '/', $relativePath);
$archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath;
$zip->addFile($realPath, $archivePath);
}
}
// ── Build packages ───────────────────────────────────────────────────────────
$isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
if ($isJoomlaPackage) {
// ── Joomla package: ZIP each sub-extension, then combine ─────────────────
echo "Building Joomla package (sub-extensions)...\n";
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n");
exit(1);
}
// ZIP each sub-extension directory
$packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: [];
foreach ($packageDirs as $pkgDir) {
$subName = basename($pkgDir);
$subZipPath = "{$outputDir}/{$subName}.zip";
$subZip = new ZipArchive();
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create sub-package ZIP: {$subZipPath}\n");
continue;
}
addDirToZip($subZip, $pkgDir, '', $excludePatterns);
$subZip->close();
$zip->addFile($subZipPath, "packages/{$subName}.zip");
echo " Sub-package: {$subName}.zip\n";
}
// Copy top-level XML and PHP files into the package root
$topLevelFiles = array_merge(
glob("{$sourceDir}/*.xml") ?: [],
glob("{$sourceDir}/*.php") ?: []
);
foreach ($topLevelFiles as $tlFile) {
if (!isExcluded(basename($tlFile), $excludePatterns)) {
$zip->addFile($tlFile, basename($tlFile));
}
}
// Include top-level directories (e.g. language/) that aren't packages/
$topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: [];
foreach ($topLevelDirs as $tlDir) {
$dirName = basename($tlDir);
if ($dirName === 'packages') {
continue;
}
addDirToZip($zip, $tlDir, $dirName, $excludePatterns);
echo " Included dir: {$dirName}/\n";
}
$zip->close();
echo "ZIP created: {$zipFile}\n";
} else {
// ── Standard extension: ZIP from source dir ──────────────────────────────
echo "Building standard extension ZIP...\n";
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n");
exit(1);
}
addDirToZip($zip, $sourceDir, '', $excludePatterns);
$zip->close();
echo "ZIP created: {$zipFile}\n";
}
// ── Build tar.gz ─────────────────────────────────────────────────────────────
$tarExcludeArgs = [];
foreach ($excludePatterns as $pattern) {
$tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern);
}
$tarCommand = sprintf(
'tar -czf %s -C %s %s .',
escapeshellarg($tarFile),
escapeshellarg($sourceDir),
implode(' ', $tarExcludeArgs)
);
$tarReturnCode = 0;
$tarOutputLines = [];
exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode);
if (!file_exists($tarFile)) {
fwrite(STDERR, "Failed to create tar.gz: {$tarFile}\n");
if ($tarOutputLines !== []) {
fwrite(STDERR, implode("\n", $tarOutputLines) . "\n");
}
exit(1);
}
echo "TAR created: {$tarFile}\n";
// ── Compute SHA-256 checksums ────────────────────────────────────────────────
$zipHash = hash_file('sha256', $zipFile);
$tarHash = hash_file('sha256', $tarFile);
if ($zipHash === false || $tarHash === false) {
fwrite(STDERR, "Failed to compute SHA-256 checksums\n");
exit(1);
}
$zipSha = "{$zipFile}.sha256";
$tarSha = "{$tarFile}.sha256";
file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n");
file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n");
echo "SHA-256 (ZIP): {$zipHash}\n";
echo "SHA-256 (TAR): {$tarHash}\n";
echo "sha256_zip={$zipHash}\n";
echo "zip_name={$baseName}.zip\n";
// Write to GITHUB_OUTPUT if available
$ghOutput = getenv('GITHUB_OUTPUT');
if ($ghOutput) {
file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND);
}
// ── Get release ID from tag ──────────────────────────────────────────────────
$result = giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token);
if ($result['data'] === null || !isset($result['data']['id'])) {
fwrite(STDERR, "No release found for tag: {$tag} (HTTP {$result['code']})\n");
exit(1);
}
$releaseId = (int) $result['data']['id'];
echo "Release ID: {$releaseId} (tag: {$tag})\n";
// ── Delete existing assets with same names ───────────────────────────────────
$assetsResult = giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token);
$existingAssets = $assetsResult['data'] ?? [];
$uploadNames = [
"{$baseName}.zip",
"{$baseName}.tar.gz",
"{$baseName}.zip.sha256",
"{$baseName}.tar.gz.sha256",
];
foreach ($existingAssets as $asset) {
if (!is_array($asset)) {
continue;
}
$assetName = $asset['name'] ?? '';
$assetId = $asset['id'] ?? 0;
if (in_array($assetName, $uploadNames, true) && $assetId > 0) {
giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE');
echo "Deleted existing asset: {$assetName}\n";
}
}
// ── Upload assets ────────────────────────────────────────────────────────────
$filesToUpload = [
"{$baseName}.zip" => $zipFile,
"{$baseName}.tar.gz" => $tarFile,
"{$baseName}.zip.sha256" => $zipSha,
"{$baseName}.tar.gz.sha256" => $tarSha,
];
$uploaded = 0;
foreach ($filesToUpload as $name => $localPath) {
if (!file_exists($localPath)) {
fwrite(STDERR, "File not found, skipping: {$localPath}\n");
continue;
}
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name);
$httpCode = giteaUploadAsset($uploadUrl, $token, $localPath);
$status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})";
echo "Upload: {$name}{$status}\n";
if ($httpCode >= 200 && $httpCode < 300) {
$uploaded++;
}
}
// ── Summary ──────────────────────────────────────────────────────────────────
echo "\n";
echo "Package build complete\n";
echo " Element: {$typePrefix}{$extElement}\n";
echo " Version: {$version}\n";
echo " Tag: {$tag}\n";
echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n";
exit($uploaded === count($filesToUpload) ? 0 : 1);
+316
View File
@@ -0,0 +1,316 @@
#!/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
* 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';
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];
}
}
$token = $token ?: (getenv('GA_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
{
$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;
}
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;
}
// ── 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;
}
$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 ──────────────────────────────────────────────
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);
+175 -96
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -26,153 +27,231 @@ declare(strict_types=1);
$path = '.';
$version = null;
$platform = 'joomla';
$platform = null;
$outputSummary = false;
$githubOutput = false;
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 === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--version' && isset($argv[$i + 1])) {
$version = $argv[$i + 1];
}
if ($arg === '--platform' && isset($argv[$i + 1])) {
$platform = $argv[$i + 1];
}
if ($arg === '--output-summary') {
$outputSummary = true;
}
if ($arg === '--github-output') {
$githubOutput = true;
}
}
if ($version === null) {
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n");
exit(1);
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 = [];
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++;
/**
* Record a validation result.
*
* @param string $check Check name
* @param string $status PASS, FAIL, or WARN
* @param string $details Human-readable details
*/
function addResult(string $check, string $status, string $details): void
{
global $pass, $fail, $warn, $results;
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') {
$pass++;
} elseif ($status === 'FAIL') {
$fail++;
} elseif ($status === 'WARN') {
$warn++;
}
}
// 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');
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");
}
$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');
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}`");
}
$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; }
if (file_exists("{$root}/{$lf}")) {
$licenseFound = true;
break;
}
}
addResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
// 4. Platform-specific checks
if ($platform === 'joomla') {
// Find XML manifest
$manifest = null;
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs as $dir) {
if (!is_dir($dir)) continue;
foreach (glob("{$dir}/*.xml") as $xmlFile) {
$content = file_get_contents($xmlFile);
if (strpos($content, '<extension') !== false) {
$manifest = $xmlFile;
break 2;
}
}
}
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");
} else {
addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`");
}
} else {
addResult('Manifest version', 'FAIL', 'No <version> tag in manifest');
}
}
// Find XML manifest
$manifest = null;
$searchDirs = ["{$root}/src", $root];
foreach ($searchDirs 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) {
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");
} else {
addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`");
}
} else {
addResult('Manifest version', 'FAIL', 'No <version> tag in manifest');
}
}
// 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");
}
}
// 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));
}
}
$modFile = null;
foreach (['src', 'htdocs'] as $sd) {
$pattern = "{$root}/{$sd}/mod*.class.php";
$matches = glob($pattern);
if (!empty($matches)) {
$modFile = $matches[0];
break;
}
}
if ($modFile === null) {
addResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
} else {
$mc = file_get_contents($modFile);
if (preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc)) {
addResult('Dolibarr version', 'PASS', "`{$version}` matches");
} else {
addResult('Dolibarr version', 'FAIL', "`{$version}` not found in " . basename($modFile));
}
}
}
// 5. composer.json version (if present)
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}`");
}
}
$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 .= "| {$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 Validation\n\n{$table}\n", FILE_APPEND);
}
$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);
+286 -194
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -42,174 +43,255 @@ $outputFile = null;
$githubOutput = false;
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;
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;
}
}
if ($version === null) {
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n");
exit(1);
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n");
exit(1);
}
$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,
};
}
$detectedName = (string)($mokoXml->identity->name ?? $repo);
$detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? '');
}
}
// -- Locate Joomla manifest ---------------------------------------------------
$manifest = null;
// 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;
}
if (strpos(file_get_contents($f), '<extension') !== false) {
$manifest = $f;
break;
}
}
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;
}
}
}
$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;
}
}
}
}
if ($manifest === null) {
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
exit(1);
if ($manifest === null && $detectedPlatform === 'joomla') {
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
exit(1);
}
// -- Parse extension metadata -------------------------------------------------
$xml = file_get_contents($manifest);
// Extract fields via regex (more portable than SimpleXML for malformed manifests)
$extName = '';
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) $extName = $m[1];
$extType = '';
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
$extElement = '';
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
// For packages, prefer <packagename> to avoid pkg_pkg_ duplication
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;
}
}
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
$extClient = '';
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) $extClient = $m[1];
$extFolder = '';
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1];
$targetPlatform = '';
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) $targetPlatform = $m[1];
if (empty($targetPlatform)) {
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
}
$phpMinimum = '';
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) $phpMinimum = $m[1];
if ($manifest !== null) {
// Joomla manifest found — parse extension metadata from it
$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 {
// 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=\".*\" />";
}
// Resolve language key names (e.g. PLG_SYSTEM_MOKOJOOMTOS)
if (preg_match('/^[A-Z_]+$/', $extName)) {
$iniFiles = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (preg_match('/\.sys\.ini$/i', $file->getFilename())) {
$iniFiles[] = $file->getPathname();
}
}
foreach ($iniFiles as $ini) {
$content = file_get_contents($ini);
if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) {
$extName = $m[1];
break;
}
}
$iniFiles = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (preg_match('/\.sys\.ini$/i', $file->getFilename())) {
$iniFiles[] = $file->getPathname();
}
}
foreach ($iniFiles as $ini) {
$content = file_get_contents($ini);
if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) {
$extName = $m[1];
break;
}
}
}
// Fallbacks
if (empty($extName)) $extName = $repo ?: basename($root);
if (empty($extType)) $extType = 'component';
if (empty($extName)) {
$extName = $repo ?: basename($root);
}
if (empty($extType)) {
$extType = 'component';
}
// -- 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;
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";
}
$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";
}
}
}
// -- Stability suffix map -----------------------------------------------------
$stabilitySuffixMap = [
'stable' => '',
'rc' => '-rc',
'beta' => '-beta',
'alpha' => '-alpha',
'development' => '-dev',
'stable' => '',
'rc' => '-rc',
'beta' => '-beta',
'alpha' => '-alpha',
'development' => '-dev',
];
// Joomla <tags><tag> values — maps to Joomla's stabilityTagToInteger()
$stabilityTagMap = [
'stable' => 'stable',
'rc' => 'rc',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'dev',
'stable' => 'stable',
'rc' => 'rc',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'dev',
];
// Gitea release tag names (used in download/info URLs)
$releaseTagMap = [
'stable' => 'stable',
'rc' => 'release-candidate',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'development',
'stable' => 'stable',
'rc' => 'release-candidate',
'beta' => 'beta',
'alpha' => 'alpha',
'development' => 'development',
];
// -- Build update entries -----------------------------------------------------
@@ -221,70 +303,78 @@ $primaryVersion = $version . $primarySuffix;
// to installed extensions. Without it, extension_id=0 in #__updates.
$clientTag = '';
if (!empty($extClient)) {
$clientTag = " <client>{$extClient}</client>";
$clientTag = " <client>{$extClient}</client>";
} else {
$clientTag = ' <client>site</client>';
$clientTag = ' <client>site</client>';
}
// Build folder tag
$folderTag = '';
if (!empty($extFolder) && $extType === 'plugin') {
$folderTag = " <folder>{$extFolder}</folder>";
$folderTag = " <folder>{$extFolder}</folder>";
}
// PHP minimum tag
$phpTag = '';
if (!empty($phpMinimum)) {
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
}
// SHA tag
$shaTag = '';
if (!empty($sha)) {
$shaTag = " <sha256>{$sha}</sha256>";
$shaTag = " <sha256>{$sha}</sha256>";
}
/**
* Build a single <update> entry for a given stability tag
*/
function buildEntry(
string $tagName,
string $entryVersion,
string $entryDownloadUrl,
string $extName,
string $extElement,
string $extType,
string $clientTag,
string $folderTag,
string $infoUrl,
string $targetPlatform,
string $phpTag,
string $shaTag
string $tagName,
string $entryVersion,
string $entryDownloadUrl,
string $extName,
string $extElement,
string $extType,
string $clientTag,
string $folderTag,
string $infoUrl,
string $targetPlatform,
string $phpTag,
string $shaTag
): string {
$lines = [];
$lines[] = ' <update>';
$lines[] = " <name>{$extName}</name>";
$lines[] = " <description>{$extName} update</description>";
// Element in updates.xml must match what Joomla stores in #__extensions
// For packages: pkg_elementname. For plugins: elementname (folder handles grouping).
$dbElement = ($extType === 'package') ? "pkg_{$extElement}" : $extElement;
$lines[] = " <element>{$dbElement}</element>";
$lines[] = " <type>{$extType}</type>";
$lines[] = " <version>{$entryVersion}</version>";
if (!empty($clientTag)) $lines[] = $clientTag;
if (!empty($folderTag)) $lines[] = $folderTag;
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
$lines[] = " <infourl title=\"{$extName}\">{$infoUrl}</infourl>";
$lines[] = ' <downloads>';
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$entryDownloadUrl}</downloadurl>";
$lines[] = ' </downloads>';
if (!empty($shaTag)) $lines[] = $shaTag;
$lines[] = " {$targetPlatform}";
if (!empty($phpTag)) $lines[] = $phpTag;
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
$lines[] = ' </update>';
return implode("\n", $lines);
$lines = [];
$lines[] = ' <update>';
$lines[] = " <name>{$extName}</name>";
$lines[] = " <description>{$extName} update</description>";
// Element in updates.xml must match what Joomla stores in #__extensions
// For packages: pkg_elementname. For plugins: elementname (folder handles grouping).
$dbElement = ($extType === 'package') ? "pkg_{$extElement}" : $extElement;
$lines[] = " <element>{$dbElement}</element>";
$lines[] = " <type>{$extType}</type>";
$lines[] = " <version>{$entryVersion}</version>";
if (!empty($clientTag)) {
$lines[] = $clientTag;
}
if (!empty($folderTag)) {
$lines[] = $folderTag;
}
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
$lines[] = " <infourl title=\"{$extName}\">{$infoUrl}</infourl>";
$lines[] = ' <downloads>';
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$entryDownloadUrl}</downloadurl>";
$lines[] = ' </downloads>';
if (!empty($shaTag)) {
$lines[] = $shaTag;
}
$lines[] = " {$targetPlatform}";
if (!empty($phpTag)) {
$lines[] = $phpTag;
}
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
$lines[] = ' </update>';
return implode("\n", $lines);
}
// -- Determine which channels to write ----------------------------------------
@@ -295,7 +385,9 @@ function buildEntry(
// When dev releases, only dev is updated; everything else is preserved.
$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable'];
$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels);
if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable
if ($stabilityIndex === false) {
$stabilityIndex = 4; // default to stable
}
// Write entries for the current channel AND all lower channels (cascade down)
// All cascaded entries point to the CURRENT release (the highest stability being built)
@@ -306,25 +398,25 @@ $channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
for ($i = 0; $i <= $stabilityIndex; $i++) {
$channelName = $allChannels[$i];
$joomlaTag = $stabilityTagMap[$channelName] ?? $channelName;
// Only attach SHA to the primary channel entry
$entrySha = ($i === $stabilityIndex) ? $shaTag : '';
$channelName = $allChannels[$i];
$joomlaTag = $stabilityTagMap[$channelName] ?? $channelName;
// Only attach SHA to the primary channel entry
$entrySha = ($i === $stabilityIndex) ? $shaTag : '';
$entries[] = buildEntry(
$joomlaTag,
$channelVersion,
$channelDownloadUrl,
$extName,
$extElement,
$extType,
$clientTag,
$folderTag,
$channelInfoUrl,
$targetPlatform,
$phpTag,
$entrySha
);
$entries[] = buildEntry(
$joomlaTag,
$channelVersion,
$channelDownloadUrl,
$extName,
$extElement,
$extType,
$clientTag,
$folderTag,
$channelInfoUrl,
$targetPlatform,
$phpTag,
$entrySha
);
}
// -- Preserve existing entries for channels not being updated -----------------
@@ -332,27 +424,27 @@ $dest = $outputFile ?? "{$root}/updates.xml";
$preservedEntries = [];
if (file_exists($dest)) {
$existingXml = @simplexml_load_file($dest);
if ($existingXml) {
// Joomla tags we're writing — don't preserve these
$writtenChannels = [];
for ($i = 0; $i <= $stabilityIndex; $i++) {
$writtenChannels[] = $stabilityTagMap[$allChannels[$i]] ?? $allChannels[$i];
}
// Also match legacy/alternate tag names (e.g. 'development' = 'dev')
$writtenChannels[] = 'development'; // alias for 'dev'
$existingXml = @simplexml_load_file($dest);
if ($existingXml) {
// Joomla tags we're writing — don't preserve these
$writtenChannels = [];
for ($i = 0; $i <= $stabilityIndex; $i++) {
$writtenChannels[] = $stabilityTagMap[$allChannels[$i]] ?? $allChannels[$i];
}
// Also match legacy/alternate tag names (e.g. 'development' = 'dev')
$writtenChannels[] = 'development'; // alias for 'dev'
foreach ($existingXml->update as $existingUpdate) {
$existingTag = '';
if (isset($existingUpdate->tags->tag)) {
$existingTag = (string) $existingUpdate->tags->tag;
}
// Keep entries for channels we're NOT overwriting
if (!empty($existingTag) && !in_array($existingTag, $writtenChannels, true)) {
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
}
}
}
foreach ($existingXml->update as $existingUpdate) {
$existingTag = '';
if (isset($existingUpdate->tags->tag)) {
$existingTag = (string) $existingUpdate->tags->tag;
}
// Keep entries for channels we're NOT overwriting
if (!empty($existingTag) && !in_array($existingTag, $writtenChannels, true)) {
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
}
}
}
}
// -- Write updates.xml --------------------------------------------------------
+104 -18
View File
@@ -9,7 +9,7 @@
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_bump.php
* BRIEF: Auto-increment patch version — checks both README.md and manifest XML, uses the higher version as base
* BRIEF: Auto-increment version — manifest.xml is canonical, cascades to all XML and MD files
*/
declare(strict_types=1);
@@ -24,7 +24,18 @@ foreach ($argv as $i => $arg) {
$root = realpath($path) ?: $path;
// ── Read version from README.md ──────────────────────────────────────────────
// -- 1. Read version from .mokogitea/manifest.xml (canonical) --
$mokoVersion = null;
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
$mokoContent = '';
if (file_exists($mokoManifest)) {
$mokoContent = file_get_contents($mokoManifest);
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-([a-z]+))?</version>|', $mokoContent, $m)) {
$mokoVersion = $m[1];
}
}
// -- 2. Fallback: README.md --
$readmeVersion = null;
$readme = "{$root}/README.md";
$readmeContent = '';
@@ -35,10 +46,8 @@ if (file_exists($readme)) {
}
}
// ── Read version from Joomla manifest XML ────────────────────────────────────
// -- 3. Fallback: Joomla manifest XML --
$manifestVersion = null;
// Check package manifest first (pkg_*.xml), then sub-extension manifests
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
@@ -47,36 +56,36 @@ $manifestFiles = array_merge(
glob("{$root}/*.xml") ?: []
);
$manifestSuffix = '';
foreach ($manifestFiles as $xmlFile) {
$xmlContent = file_get_contents($xmlFile);
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
continue;
}
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-([a-z]+))?</version>|', $xmlContent, $xm)) {
$candidate = $xm[1];
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
$manifestVersion = $candidate;
$manifestSuffix = isset($xm[2]) ? $xm[2] : '';
}
}
}
// ── Use the higher version as base ───────────────────────────────────────────
// -- Use the highest version as base --
$baseVersion = null;
if ($readmeVersion !== null && $manifestVersion !== null) {
$baseVersion = version_compare($manifestVersion, $readmeVersion, '>') ? $manifestVersion : $readmeVersion;
} elseif ($manifestVersion !== null) {
$baseVersion = $manifestVersion;
} elseif ($readmeVersion !== null) {
$baseVersion = $readmeVersion;
$candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
foreach ($candidates as $v) {
if ($baseVersion === null || version_compare($v, $baseVersion, '>')) {
$baseVersion = $v;
}
}
if ($baseVersion === null) {
fwrite(STDERR, "No version found in README.md or manifest XML\n");
fwrite(STDERR, "No version found in manifest.xml, README.md, or Joomla XML\n");
exit(1);
}
// ── Parse and bump ───────────────────────────────────────────────────────────
// -- Parse and bump --
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) {
fwrite(STDERR, "Invalid version format: {$baseVersion}\n");
exit(1);
@@ -99,7 +108,22 @@ switch ($type) {
$new = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
// ── Update README.md ─────────────────────────────────────────────────────────
// -- Determine suffix to preserve (from whichever source had the version) --
$suffix = !empty($manifestSuffix) ? $manifestSuffix : '';
$newFull = $suffix !== '' ? "{$new}-{$suffix}" : $new;
// -- Update .mokogitea/manifest.xml (canonical target, no suffix) --
if (file_exists($mokoManifest) && !empty($mokoContent)) {
$updated = preg_replace(
'|<version>\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?</version>|',
"<version>{$new}</version>",
$mokoContent,
1
);
file_put_contents($mokoManifest, $updated);
}
// -- Update README.md --
if (file_exists($readme) && !empty($readmeContent)) {
$updated = preg_replace(
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
@@ -110,5 +134,67 @@ if (file_exists($readme) && !empty($readmeContent)) {
file_put_contents($readme, $updated);
}
echo "{$old}{$new}\n";
// -- Cascade to ALL Joomla extension XML manifests --
$xmlPatterns = [
"{$root}/src/pkg_*.xml",
"{$root}/src/*.xml",
"{$root}/src/packages/*/*.xml",
"{$root}/*.xml",
];
$updatedFiles = [];
foreach ($xmlPatterns as $pattern) {
foreach (glob($pattern) ?: [] as $xmlFile) {
$content = file_get_contents($xmlFile);
// Only update files that have an <extension> tag (Joomla manifests)
if (strpos($content, '<extension') === false) {
continue;
}
$newContent = preg_replace(
'|<version>\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?</version>|',
"<version>{$newFull}</version>",
$content
);
if ($newContent !== $content) {
file_put_contents($xmlFile, $newContent);
$updatedFiles[] = substr($xmlFile, strlen($root) + 1);
}
}
}
if (!empty($updatedFiles)) {
fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n");
}
// -- Update package.json (Node.js / MCP) --
$packageJsonFile = "{$root}/package.json";
if (file_exists($packageJsonFile)) {
$pkgContent = file_get_contents($packageJsonFile);
$updatedPkg = preg_replace(
'/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(")/m',
'${1}' . $new . '${2}',
$pkgContent
);
if ($updatedPkg !== $pkgContent) {
file_put_contents($packageJsonFile, $updatedPkg);
fwrite(STDERR, "Updated package.json\n");
}
}
// -- Update pyproject.toml (Python) --
$pyprojectFile = "{$root}/pyproject.toml";
if (file_exists($pyprojectFile)) {
$pyContent = file_get_contents($pyprojectFile);
$updatedPy = preg_replace(
'/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(")/m',
'${1}' . $new . '${2}',
$pyContent
);
if ($updatedPy !== $pyContent) {
file_put_contents($pyprojectFile, $updatedPy);
fwrite(STDERR, "Updated pyproject.toml\n");
}
}
echo "{$old} -> {$newFull}\n";
exit(0);
+90 -12
View File
@@ -9,7 +9,7 @@
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/version_read.php
* BRIEF: Read version from README.md or manifest XML — outputs the higher of the two
* BRIEF: Read version — manifest.xml is canonical, falls back to README.md and Joomla XML
*/
declare(strict_types=1);
@@ -23,7 +23,26 @@ foreach ($argv as $i => $arg) {
$root = realpath($path) ?: $path;
// ── Read from README.md ──────────────────────────────────────────────────────
// -- 1. Read from .mokogitea/manifest.xml (canonical source) --
$mokoVersion = null;
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) {
$xml = @simplexml_load_file($mokoManifest);
if ($xml !== false) {
$v = (string)($xml->identity->version ?? '');
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $v)) {
$mokoVersion = $v;
}
}
}
// If manifest.xml has a version, that is authoritative
if ($mokoVersion !== null) {
echo $mokoVersion . "\n";
exit(0);
}
// -- 2. Fallback: README.md --
$readmeVersion = null;
$readme = "{$root}/README.md";
if (file_exists($readme)) {
@@ -33,8 +52,9 @@ if (file_exists($readme)) {
}
}
// ── Read from Joomla manifest XML ────────────────────────────────────────────
// -- 3. Fallback: Joomla manifest XML --
$manifestVersion = null;
$manifestVersionSuffix = '';
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
@@ -47,28 +67,86 @@ foreach ($manifestFiles as $xmlFile) {
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
continue;
}
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})(-[a-z]+)?</version>|', $xmlContent, $xm)) {
$candidate = $xm[1];
$candidateSuffix = isset($xm[2]) ? $xm[2] : '';
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
$manifestVersion = $candidate;
$manifestVersionSuffix = $candidateSuffix;
}
}
}
// ── Output the higher version ────────────────────────────────────────────────
// -- 4. Fallback: package.json (Node.js / MCP) --
$packageJsonVersion = null;
$packageJsonFile = "{$root}/package.json";
if (file_exists($packageJsonFile)) {
$pkgData = json_decode(file_get_contents($packageJsonFile), true);
if (isset($pkgData['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkgData['version'])) {
$packageJsonVersion = $pkgData['version'];
}
}
// -- 5. Fallback: pyproject.toml (Python) --
$pyprojectVersion = null;
$pyprojectFile = "{$root}/pyproject.toml";
if (file_exists($pyprojectFile)) {
$pyContent = file_get_contents($pyprojectFile);
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $pyContent, $pm)) {
$pyprojectVersion = $pm[1];
}
}
// -- Output the higher version --
$candidates = array_filter([
$readmeVersion,
$manifestVersion,
$packageJsonVersion,
$pyprojectVersion,
]);
$version = null;
if ($readmeVersion !== null && $manifestVersion !== null) {
$version = version_compare($manifestVersion, $readmeVersion, '>') ? $manifestVersion : $readmeVersion;
} elseif ($manifestVersion !== null) {
$version = $manifestVersion;
} elseif ($readmeVersion !== null) {
$version = $readmeVersion;
$versionSource = '';
foreach ($candidates as $candidate) {
if ($version === null || version_compare($candidate, $version, '>')) {
$version = $candidate;
$versionSource = ($candidate === $manifestVersion) ? 'manifest' : 'other';
}
}
if ($version === null) {
fwrite(STDERR, "No version found in README.md or manifest XML\n");
fwrite(STDERR, "No version found in manifest.xml, README.md, Joomla XML, package.json, or pyproject.toml\n");
exit(1);
}
// Append suffix if the version came from a Joomla manifest with a suffix
if ($versionSource === 'manifest' && !empty($manifestVersionSuffix)) {
$version .= $manifestVersionSuffix;
}
// -- Backfill: if manifest.xml exists but lacks <version>, insert it --
if (file_exists($mokoManifest)) {
$content = file_get_contents($mokoManifest);
if (!preg_match('|<version>\d{2}\.\d{2}\.\d{2}</version>|', $content)) {
if (strpos($content, '<license') !== false) {
$content = preg_replace(
'|(\s*<license)|',
"\n <version>{$version}</version>\$1",
$content,
1
);
} elseif (strpos($content, '</identity>') !== false) {
$content = preg_replace(
'|(</identity>)|',
" <version>{$version}</version>\n \$1",
$content,
1
);
}
file_put_contents($mokoManifest, $content);
fwrite(STDERR, "Backfilled manifest.xml with version {$version}\n");
}
}
echo $version . "\n";
exit(0);
+319
View File
@@ -0,0 +1,319 @@
#!/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
* PATH: /cli/version_reset_dev.php
* BRIEF: Reset platform version to 'development' on a branch via Gitea API
*
* Usage:
* php version_reset_dev.php --token TOKEN --api-base URL
* php version_reset_dev.php --token TOKEN --api-base URL --branch dev
* php version_reset_dev.php --token TOKEN --api-base URL --platform dolibarr
* php version_reset_dev.php --token TOKEN --api-base URL --path /repo/root
*
* This replaces the inline curl+python3+sed block previously used in
* auto-release.yml to reset Dolibarr's $this->version on the dev branch
* after a stable release.
*/
declare(strict_types=1);
// ── Argument parsing ─────────────────────────────────────────────────────────
$token = null;
$apiBase = null;
$branch = 'dev';
$platform = null;
$path = null;
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 = rtrim($argv[$i + 1], '/');
}
if ($arg === '--branch' && isset($argv[$i + 1])) {
$branch = $argv[$i + 1];
}
if ($arg === '--platform' && isset($argv[$i + 1])) {
$platform = $argv[$i + 1];
}
if ($arg === '--path' && isset($argv[$i + 1])) {
$path = $argv[$i + 1];
}
if ($arg === '--help' || $arg === '-h') {
printUsage();
exit(0);
}
}
// Allow token from environment
if ($token === null) {
$envToken = getenv('GA_TOKEN');
if ($envToken !== false && $envToken !== '') {
$token = $envToken;
}
}
if ($token === null) {
$envToken = getenv('GITEA_TOKEN');
if ($envToken !== false && $envToken !== '') {
$token = $envToken;
}
}
if ($token === null || $apiBase === null) {
fwrite(STDERR, "Error: --token and --api-base are required.\n\n");
printUsage();
exit(1);
}
// ── Platform detection ───────────────────────────────────────────────────────
if ($platform === null && $path !== null) {
$platform = detectPlatform($path);
if ($platform !== null) {
echo "Detected platform: {$platform}\n";
}
}
if ($platform === null) {
fwrite(STDERR, "Error: could not determine platform. Use --platform or --path.\n");
exit(1);
}
// ── Dispatch by platform ─────────────────────────────────────────────────────
$changed = 0;
if (in_array($platform, ['dolibarr', 'crm-module'], true)) {
$changed = resetDolibarrVersion($apiBase, $token, $branch);
} elseif (in_array($platform, ['joomla', 'waas-component'], true)) {
echo "Joomla version reset is not yet implemented — skipping.\n";
} else {
echo "Platform '{$platform}' has no version-reset logic — skipping.\n";
}
echo "Reset {$changed} file(s) to 'development' on branch '{$branch}'.\n";
exit(0);
// ══════════════════════════════════════════════════════════════════════════════
// Helper functions
// ══════════════════════════════════════════════════════════════════════════════
/**
* Print usage information to stdout.
*
* @return void
*/
function printUsage(): void
{
echo <<<'USAGE'
Reset platform version to 'development' on a branch via Gitea API.
Usage:
php version_reset_dev.php --token TOKEN --api-base URL [options]
Required:
--token TOKEN Gitea API token (also reads GA_TOKEN / GITEA_TOKEN env)
--api-base URL Gitea API base URL for the repo
e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo
Options:
--branch BRANCH Target branch (default: dev)
--platform TYPE Platform type: dolibarr, crm-module, joomla, waas-component
--path DIR Repo root for auto-detecting platform from manifest.xml
--help Show this help
USAGE;
}
/**
* Detect the platform type from a repo's .mokogitea/manifest.xml file.
*
* @param string $repoPath Path to the repository root
* @return string|null The detected platform, or null if detection fails
*/
function detectPlatform(string $repoPath): ?string
{
$root = realpath($repoPath) ?: $repoPath;
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (!file_exists($manifestXml)) {
return null;
}
$xml = @simplexml_load_file($manifestXml);
if ($xml === false) {
return null;
}
if (isset($xml->governance->platform)) {
$platform = (string) $xml->governance->platform;
if ($platform !== '') {
return $platform;
}
}
return null;
}
/**
* Make a Gitea API call and return the decoded JSON response.
*
* @param string $url Full API URL
* @param string $token Gitea API token
* @param string $method HTTP method (GET, PUT, POST, DELETE)
* @param string|null $body JSON request body, or null for bodiless requests
* @return array<string, mixed>|null Decoded JSON response, or null on failure
*/
function giteaApiCall(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
{
$ch = curl_init($url);
if ($ch === false) {
fwrite(STDERR, "Error: curl_init() failed for {$url}\n");
return null;
}
$headers = [
"Authorization: token {$token}",
'Accept: application/json',
];
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
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 || !is_string($response) || $response === '') {
return null;
}
$data = json_decode($response, true);
if (!is_array($data)) {
return null;
}
return $data;
}
/**
* Reset Dolibarr module version to 'development' on the target branch.
*
* Searches the repository tree for mod*.class.php files that contain
* `extends DolibarrModules`, then replaces `$this->version = '...'`
* with `$this->version = 'development'` via the Gitea file contents API.
*
* @param string $apiBase Gitea API base URL for the repo
* @param string $token Gitea API token
* @param string $branch Target branch name
* @return int Number of files modified
*/
function resetDolibarrVersion(string $apiBase, string $token, string $branch): int
{
// Search the repo tree for mod*.class.php files
$treeUrl = "{$apiBase}/git/trees/{$branch}?recursive=true";
$tree = giteaApiCall($treeUrl, $token);
if ($tree === null || !isset($tree['tree']) || !is_array($tree['tree'])) {
fwrite(STDERR, "Error: could not read repository tree for branch '{$branch}'.\n");
return 0;
}
// Find candidate files: mod*.class.php anywhere in the tree
$candidates = [];
foreach ($tree['tree'] as $entry) {
if (!isset($entry['path']) || !is_string($entry['path'])) {
continue;
}
$basename = basename($entry['path']);
if (preg_match('/^mod[A-Za-z0-9_]+\.class\.php$/', $basename)) {
$candidates[] = $entry['path'];
}
}
if (empty($candidates)) {
echo "No mod*.class.php files found on branch '{$branch}'.\n";
return 0;
}
$changed = 0;
foreach ($candidates as $filePath) {
// GET file contents via API
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $filePath)));
$fileUrl = "{$apiBase}/contents/{$encodedPath}?ref={$branch}";
$fileData = giteaApiCall($fileUrl, $token);
if ($fileData === null || !isset($fileData['content'])) {
echo "Skipping {$filePath}: could not fetch contents.\n";
continue;
}
// Decode base64 content
$rawContent = is_string($fileData['content']) ? $fileData['content'] : '';
$content = base64_decode($rawContent, true);
if ($content === false) {
echo "Skipping {$filePath}: could not decode content.\n";
continue;
}
// Verify this file extends DolibarrModules
if (!str_contains($content, 'extends DolibarrModules')) {
continue;
}
// Replace $this->version = '...' with $this->version = 'development'
$updated = preg_replace(
'/(\$this->version\s*=\s*)[\'"][^\'"]*[\'"]/',
"\${1}'development'",
$content
);
if ($updated === null || $updated === $content) {
echo "Skipping {$filePath}: no version change needed.\n";
continue;
}
// PUT updated content back via API
$sha = $fileData['sha'] ?? '';
$putBody = json_encode([
'content' => base64_encode($updated),
'message' => 'chore(version): reset dev version [skip ci]',
'branch' => $branch,
'sha' => $sha,
]);
$putUrl = "{$apiBase}/contents/{$encodedPath}";
$result = giteaApiCall($putUrl, $token, 'PUT', $putBody);
if ($result !== null) {
echo "Reset: {$filePath} -> \$this->version = 'development'\n";
$changed++;
} else {
fwrite(STDERR, "Error: failed to update {$filePath} on branch '{$branch}'.\n");
}
}
return $changed;
}
+282
View File
@@ -0,0 +1,282 @@
#!/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
* PATH: /cli/wiki_sync.php
* VERSION: 01.00.00
* BRIEF: Sync select wiki pages from moko-platform to all template repos
*/
declare(strict_types=1);
final class WikiSync
{
private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = '';
private string $org = 'MokoConsulting';
private string $sourceRepo = 'moko-platform';
private array $targetRepos = [];
private array $pages = [];
private bool $dryRun = false;
private bool $allTemplates = false;
private int $synced = 0;
private int $created = 0;
private int $skipped = 0;
private int $errors = 0;
public function run(): int
{
$this->parseArgs();
if ($this->token === '') {
$this->log('ERROR: --token is required.');
$this->printUsage();
return 1;
}
if (empty($this->pages) && !$this->allTemplates) {
$this->log('ERROR: --page or --all-standards is required.');
$this->printUsage();
return 1;
}
// Discover template repos if --all-templates
if ($this->allTemplates || empty($this->targetRepos)) {
$this->targetRepos = $this->discoverTemplateRepos();
}
if (empty($this->targetRepos)) {
$this->log('No target repos found.');
return 0;
}
// If --all-standards, get all pages that start with uppercase
if (empty($this->pages)) {
$this->pages = $this->getStandardsPages();
}
$this->log("Syncing " . count($this->pages) . " page(s) to " . count($this->targetRepos) . " repo(s)");
if ($this->dryRun) {
$this->log("[DRY RUN] No changes will be made.\n");
}
foreach ($this->pages as $pageName) {
$this->log("\n--- Page: {$pageName} ---");
$sourceContent = $this->getWikiPage($this->sourceRepo, $pageName);
if ($sourceContent === null) {
$this->log(" WARNING: page not found in {$this->sourceRepo}");
$this->errors++;
continue;
}
foreach ($this->targetRepos as $repo) {
$existing = $this->getWikiPage($repo, $pageName);
if ($existing !== null && $existing === $sourceContent) {
$this->log(" {$repo}: IDENTICAL (skipped)");
$this->skipped++;
continue;
}
if ($this->dryRun) {
$action = $existing !== null ? 'WOULD UPDATE' : 'WOULD CREATE';
$this->log(" {$repo}: {$action}");
continue;
}
if ($existing !== null) {
$ok = $this->updateWikiPage($repo, $pageName, $sourceContent);
$this->log(" {$repo}: " . ($ok ? 'UPDATED' : 'ERROR'));
$ok ? $this->synced++ : $this->errors++;
} else {
$ok = $this->createWikiPage($repo, $pageName, $sourceContent);
$this->log(" {$repo}: " . ($ok ? 'CREATED' : 'ERROR'));
$ok ? $this->created++ : $this->errors++;
}
}
}
$this->log("\nDone: {$this->synced} updated, {$this->created} created, {$this->skipped} skipped, {$this->errors} error(s)");
return $this->errors > 0 ? 1 : 0;
}
private function discoverTemplateRepos(): array
{
$repos = $this->apiGet("/orgs/{$this->org}/repos?limit=100");
$templates = [];
foreach ($repos as $repo) {
if (str_starts_with($repo['name'], 'Template-') && !($repo['archived'] ?? false)) {
$templates[] = $repo['name'];
}
}
sort($templates);
$this->log("Found template repos: " . implode(', ', $templates));
return $templates;
}
private function getStandardsPages(): array
{
$pages = $this->apiGet("/repos/{$this->org}/{$this->sourceRepo}/wiki/pages");
$standards = [];
foreach ($pages as $page) {
$title = $page['title'] ?? '';
// Sync pages that are all-caps with underscores (standards pages)
if (preg_match('/^[A-Z][A-Z0-9_-]+$/', $title)) {
$standards[] = $title;
}
}
sort($standards);
$this->log("Found " . count($standards) . " standards pages: " . implode(', ', $standards));
return $standards;
}
private function getWikiPage(string $repo, string $pageName): ?string
{
$data = $this->apiGet("/repos/{$this->org}/{$repo}/wiki/page/{$pageName}");
if ($data === null || !isset($data['content_base64'])) {
return null;
}
return base64_decode($data['content_base64']);
}
private function createWikiPage(string $repo, string $pageName, string $content): bool
{
$payload = json_encode([
'title' => $pageName,
'content_base64' => base64_encode($content),
]);
return $this->apiPost("/repos/{$this->org}/{$repo}/wiki/new", $payload) !== null;
}
private function updateWikiPage(string $repo, string $pageName, string $content): bool
{
$payload = json_encode([
'title' => $pageName,
'content_base64' => base64_encode($content),
]);
return $this->apiPatch("/repos/{$this->org}/{$repo}/wiki/page/{$pageName}", $payload) !== null;
}
private function apiGet(string $endpoint): ?array
{
$url = "{$this->giteaUrl}/api/v1{$endpoint}";
$opts = [
'http' => [
'method' => 'GET',
'header' => "Authorization: token {$this->token}\r\nAccept: application/json\r\n",
'ignore_errors' => true,
],
];
$ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx);
if ($result === false) return null;
$data = json_decode($result, true);
return is_array($data) ? $data : null;
}
private function apiPost(string $endpoint, string $payload): ?array
{
return $this->apiWrite('POST', $endpoint, $payload);
}
private function apiPatch(string $endpoint, string $payload): ?array
{
return $this->apiWrite('PATCH', $endpoint, $payload);
}
private function apiWrite(string $method, string $endpoint, string $payload): ?array
{
$url = "{$this->giteaUrl}/api/v1{$endpoint}";
$opts = [
'http' => [
'method' => $method,
'header' => "Authorization: token {$this->token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
'content' => $payload,
'ignore_errors' => true,
],
];
$ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx);
if ($result === false) return null;
$data = json_decode($result, true);
return is_array($data) ? $data : null;
}
private function parseArgs(): void
{
global $argv;
$args = $argv;
for ($i = 1; $i < count($args); $i++) {
switch ($args[$i]) {
case '--token':
$this->token = $args[++$i] ?? '';
break;
case '--org':
$this->org = $args[++$i] ?? '';
break;
case '--source':
$this->sourceRepo = $args[++$i] ?? '';
break;
case '--target':
$this->targetRepos[] = $args[++$i] ?? '';
break;
case '--page':
$this->pages[] = $args[++$i] ?? '';
break;
case '--all-standards':
$this->pages = []; // will be populated from source wiki
$this->allTemplates = true;
break;
case '--all-templates':
$this->allTemplates = true;
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: wiki_sync.php --token <token> [options]');
$this->log('');
$this->log('Sync wiki pages from moko-platform to template repos.');
$this->log('');
$this->log('Options:');
$this->log(' --token <token> Gitea API token (required)');
$this->log(' --org <org> Organization (default: MokoConsulting)');
$this->log(' --source <repo> Source repo (default: moko-platform)');
$this->log(' --target <repo> Target repo (can repeat; default: all Template-* repos)');
$this->log(' --page <name> Page to sync (can repeat)');
$this->log(' --all-standards Sync all UPPERCASE standards pages');
$this->log(' --all-templates Target all Template-* repos');
$this->log(' --dry-run Show what would be done');
$this->log(' --help, -h Show this help');
$this->log('');
$this->log('Examples:');
$this->log(' php wiki_sync.php --token xxx --page MANIFEST_STANDARD --all-templates');
$this->log(' php wiki_sync.php --token xxx --all-standards --all-templates --dry-run');
$this->log(' php wiki_sync.php --token xxx --page WORKFLOW_STANDARDS --target Template-Joomla');
}
private function log(string $msg): void
{
fwrite(STDERR, $msg . "\n");
}
}
(new WikiSync())->run();
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "mokoconsulting-tech/enterprise",
"description": "MokoStandards Enterprise API \u2014 PHP implementation",
"type": "library",
"version": "09.00.00",
"version": "09.01.00",
"license": "GPL-3.0-or-later",
"authors": [
{
+126
View File
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
MokoStandards Manifest Schema v09.01.00
Defines the structure of .mokogitea/manifest.xml
Validate: xmllint - -schema definitions/manifest-schema.xsd .mokogitea/manifest.xml
-->
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:moko="https://standards.mokoconsulting.tech/moko-platform/1.0"
targetNamespace="https://standards.mokoconsulting.tech/moko-platform/1.0"
elementFormDefault="qualified"
version="09.01.00">
<!-- Root element -->
<xs:element name="moko-platform">
<xs:complexType>
<xs:sequence>
<xs:element name="identity" type="moko:identityType"/>
<xs:element name="governance" type="moko:governanceType"/>
<xs:element name="build" type="moko:buildType"/>
<xs:element name="deploy" type="moko:deployType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="schema-version" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
<!-- Identity block -->
<xs:complexType name="identityType">
<xs:sequence>
<xs:element name="name" type="xs:string"/>
<xs:element name="org" type="xs:string"/>
<xs:element name="description" type="xs:string"/>
<xs:element name="version" type="moko:versionType"/>
<xs:element name="license" type="moko:licenseType"/>
</xs:sequence>
</xs:complexType>
<!-- Version format: XX.YY.ZZ -->
<xs:simpleType name="versionType">
<xs:restriction base="xs:string">
<xs:pattern value="\d{2}\.\d{2}\.\d{2}"/>
</xs:restriction>
</xs:simpleType>
<!-- License with SPDX attribute -->
<xs:complexType name="licenseType">
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="spdx" type="xs:string" use="required"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
<!-- Governance block -->
<xs:complexType name="governanceType">
<xs:sequence>
<xs:element name="platform" type="moko:platformType"/>
<xs:element name="standards-version" type="moko:versionType"/>
<xs:element name="standards-source" type="xs:anyURI"/>
<xs:element name="last-synced" type="xs:dateTime" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<!-- Allowed platform values -->
<xs:simpleType name="platformType">
<xs:restriction base="xs:string">
<xs:enumeration value="joomla"/>
<xs:enumeration value="dolibarr"/>
<xs:enumeration value="go"/>
<xs:enumeration value="node"/>
<xs:enumeration value="rust"/>
<xs:enumeration value="python"/>
<xs:enumeration value="generic"/>
</xs:restriction>
</xs:simpleType>
<!-- Build block -->
<xs:complexType name="buildType">
<xs:sequence>
<xs:element name="language" type="moko:languageType"/>
<xs:element name="package-type" type="moko:packageType"/>
<xs:element name="entry-point" type="xs:string"/>
</xs:sequence>
</xs:complexType>
<!-- Allowed languages -->
<xs:simpleType name="languageType">
<xs:restriction base="xs:string">
<xs:enumeration value="PHP"/>
<xs:enumeration value="Go"/>
<xs:enumeration value="JavaScript"/>
<xs:enumeration value="TypeScript"/>
<xs:enumeration value="Rust"/>
<xs:enumeration value="Python"/>
<xs:enumeration value="HCL"/>
<xs:enumeration value="Shell"/>
</xs:restriction>
</xs:simpleType>
<!-- Allowed package types -->
<xs:simpleType name="packageType">
<xs:restriction base="xs:string">
<xs:enumeration value="joomla-extension"/>
<xs:enumeration value="dolibarr"/>
<xs:enumeration value="application"/>
<xs:enumeration value="library"/>
<xs:enumeration value="mcp-server"/>
<xs:enumeration value="generic"/>
</xs:restriction>
</xs:simpleType>
<!-- Deploy block (optional) -->
<xs:complexType name="deployType">
<xs:sequence>
<xs:element name="source-dir" type="xs:string" minOccurs="0"/>
<xs:element name="remote-subdir" type="xs:string" minOccurs="0"/>
<xs:element name="excludes" type="xs:string" minOccurs="0"/>
<xs:element name="dev-host" type="xs:string" minOccurs="0"/>
<xs:element name="demo-host" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
</xs:schema>
+2
View File
@@ -58,6 +58,8 @@ use RuntimeException;
* $transaction->logSecurityEvent('file_modified', ['file' => 'README.md']);
* $transaction->end();
* ```
*
* @since 04.00.00
*/
class AuditLogger
{
+8
View File
@@ -18,6 +18,14 @@ declare(strict_types=1);
namespace MokoEnterprise;
/**
* Configuration Validator
*
* Validates moko-platform configuration files (YAML, JSON, HCL)
* against expected schemas and reports errors.
*
* @since 04.00.00
*/
class ConfigValidator
{
/** @var array<int, string> */
+2
View File
@@ -43,6 +43,8 @@ namespace MokoEnterprise;
* 'inline_content' => string — rendered template content (ready to push)
* 'destination' => string — path in the target repository
* 'always_overwrite' => bool — true: overwrite existing file; false: create-only
*
* @since 04.00.00
*/
class DefinitionParser
{
+196
View File
@@ -0,0 +1,196 @@
#!/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.Enterprise
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /lib/Enterprise/ManifestReader.php
* BRIEF: Read and parse .mokogitea/manifest.xml — shared across all CLI tools
*/
declare(strict_types=1);
namespace MokoEnterprise;
/**
* Manifest Reader
*
* Parses .mokogitea/manifest.xml and provides typed access to all fields.
* Used by CLI tools and the Enterprise library to determine platform,
* build configuration, and deployment settings from the repository manifest.
*
* @since 09.01.00
*/
class ManifestReader
{
/** @var array<string, string> Parsed manifest fields */
private array $fields = [];
/** @var bool Whether a manifest was found and parsed */
private bool $loaded = false;
/**
* Load manifest from a repository root directory.
*
* @param string $root Repository root path
* @return self
*/
public static function fromPath(string $root): self
{
$reader = new self();
$reader->load($root);
return $reader;
}
/**
* Load and parse the manifest file.
*
* @param string $root Repository root path
*/
public function load(string $root): void
{
$candidates = [
"{$root}/.mokogitea/manifest.xml",
"{$root}/.mokogitea/.manifest.xml",
"{$root}/.mokogitea/.moko-platform",
];
$manifestFile = null;
foreach ($candidates as $candidate) {
if (file_exists($candidate)) {
$manifestFile = $candidate;
break;
}
}
if ($manifestFile === null) {
return;
}
$xml = @simplexml_load_file($manifestFile);
if ($xml === false) {
// Fallback: YAML legacy format
$content = file_get_contents($manifestFile);
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
$this->fields['platform'] = trim($m[1], " \t\n\r\"'");
}
$this->loaded = true;
return;
}
$this->fields = [
'name' => (string)($xml->identity->name ?? ''),
'org' => (string)($xml->identity->org ?? ''),
'description' => (string)($xml->identity->description ?? ''),
'license' => (string)($xml->identity->license ?? ''),
'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''),
'version' => (string)($xml->identity->version ?? ''),
'platform' => (string)($xml->governance->platform ?? ''),
'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''),
'language' => (string)($xml->build->language ?? ''),
'package-type' => (string)($xml->build->{"package-type"} ?? ''),
'entry-point' => (string)($xml->build->{"entry-point"} ?? ''),
'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''),
'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
];
// Strip empty values
$this->fields = array_filter($this->fields, fn($v) => $v !== '');
$this->loaded = true;
}
/**
* Whether a manifest was found and loaded.
*
* @return bool
*/
public function isLoaded(): bool
{
return $this->loaded;
}
/**
* Get a single field value.
*
* @param string $key Field name (e.g. 'platform', 'package-type')
* @param string $default Default value if field is missing
* @return string
*/
public function get(string $key, string $default = ''): string
{
return $this->fields[$key] ?? $default;
}
/**
* Get the platform slug, normalized to canonical values.
*
* @return string One of: joomla, dolibarr, generic, mcp, nodejs
*/
public function getPlatform(): string
{
$raw = $this->get('platform', 'generic');
return match ($raw) {
'waas-component' => 'joomla',
'crm-module' => 'dolibarr',
default => $raw,
};
}
/**
* Get the source/entry-point directory.
*
* @param string $root Repository root for existence checking
* @return string Resolved source directory path (e.g. 'src', 'htdocs')
*/
public function getSourceDir(string $root = ''): string
{
$entryPoint = $this->get('entry-point', '');
if ($entryPoint !== '') {
// Strip trailing filename (e.g. src/index.ts → src)
$dir = rtrim(dirname($entryPoint) === '.' ? $entryPoint : dirname($entryPoint), '/');
if ($root === '' || is_dir("{$root}/{$dir}")) {
return $dir;
}
}
// Fallback: check common directories
if ($root !== '') {
if (is_dir("{$root}/src")) {
return 'src';
}
if (is_dir("{$root}/htdocs")) {
return 'htdocs';
}
}
return 'src';
}
/**
* Get the package type for build decisions.
*
* @return string e.g. 'package', 'dolibarr', 'generic', 'mcp-server'
*/
public function getPackageType(): string
{
return $this->get('package-type', 'generic');
}
/**
* Get all parsed fields.
*
* @return array<string, string>
*/
public function getAll(): array
{
return $this->fields;
}
}
+2
View File
@@ -59,6 +59,8 @@ use DateTimeZone;
/**
* Timer class for timing operations
*
* @since 04.00.00
*/
class MetricsTimer
{
@@ -35,6 +35,8 @@ use RuntimeException;
*
* @package MokoStandards\Enterprise
* @version 04.06.10
*
* @since 04.00.00
*/
class PlatformAdapterFactory
{
@@ -24,6 +24,8 @@ namespace MokoEnterprise;
*
* Enterprise library for validating project configurations against
* project type templates and standards.
*
* @since 04.00.00
*/
class ProjectConfigValidator
{
+2
View File
@@ -24,6 +24,8 @@ namespace MokoEnterprise;
*
* Enterprise library for automatically detecting project types based on
* repository structure, configuration files, and code patterns.
*
* @since 04.00.00
*/
class ProjectTypeDetector
{
@@ -24,6 +24,8 @@ namespace MokoEnterprise;
*
* Enterprise library for performing comprehensive repository health checks
* with scoring system and category-based validation.
*
* @since 04.00.00
*/
class RepositoryHealthChecker
{
+133 -15
View File
@@ -27,6 +27,8 @@ use RuntimeException;
*
* Enterprise library for synchronizing files across multiple repositories
* based on configuration and override files.
*
* @since 04.00.00
*/
class RepositorySynchronizer
{
@@ -1081,28 +1083,140 @@ HCL;
/**
* Template repo mapping — canonical source for each platform's workflows.
* The sync engine clones these at runtime to get the latest workflow files.
*
* Template-Generic is the single source of truth for universal workflows.
* During sync, universal workflows flow: Generic → Joomla/Dolibarr → governed repos.
*/
private const TEMPLATE_REPOS = [
'joomla' => 'MokoConsulting/MokoStandards-Template-Joomla',
'dolibarr' => 'MokoConsulting/MokoStandards-Template-Dolibarr',
'generic' => 'MokoConsulting/MokoStandards-Template-Generic',
'client' => 'MokoConsulting/MokoStandards-Template-Client',
'joomla' => 'MokoConsulting/Template-Joomla',
'waas-component' => 'MokoConsulting/Template-Joomla',
'dolibarr' => 'MokoConsulting/Template-Dolibarr',
'crm-module' => 'MokoConsulting/Template-Dolibarr',
'generic' => 'MokoConsulting/Template-Generic',
'mcp' => 'MokoConsulting/Template-Generic',
'client' => 'MokoConsulting/Template-Client-WaaS',
];
/**
* Universal workflows sourced from Template-Generic and pushed to all templates.
* These are platform-agnostic — they detect platform from manifest.xml at runtime.
*/
private const UNIVERSAL_WORKFLOWS = [
'auto-release.yml',
'pre-release.yml',
];
/**
* All template repos that receive universal workflows from Template-Generic.
*/
private const TEMPLATE_SYNC_TARGETS = [
'MokoConsulting/Template-Joomla',
'MokoConsulting/Template-Dolibarr',
'MokoConsulting/Template-Client-WaaS',
];
/**
* Sync universal workflows from Template-Generic to all other template repos.
*
* This ensures Template-Generic is the single source of truth for universal
* workflows (auto-release.yml, pre-release.yml). Called once at the start
* of a bulk sync before processing individual repos.
*
* @param string $org Organization name
* @return int Number of files updated across template repos
*/
public function syncUniversalWorkflowsToTemplates(string $org): int
{
$wfDir = $this->adapter->getWorkflowDir();
$genericRepo = self::TEMPLATE_REPOS['generic'];
$genericParts = explode('/', $genericRepo);
$genericOrg = $genericParts[0];
$genericName = $genericParts[1];
$updated = 0;
// Read universal workflow files from Template-Generic
$sourceFiles = [];
foreach (self::UNIVERSAL_WORKFLOWS as $wfName) {
$path = "{$wfDir}/{$wfName}";
try {
$file = $this->adapter->getFileContents($genericOrg, $genericName, $path, 'dev');
$content = $file['content'] ?? '';
if (!empty($content)) {
$sourceFiles[$wfName] = [
'content' => $content,
'path' => $path,
];
$this->logger->logInfo("Read universal workflow: {$wfName} from {$genericRepo}");
}
} catch (Exception $e) {
$this->logger->logWarning("Failed to read {$wfName} from {$genericRepo}: " . $e->getMessage());
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
if (empty($sourceFiles)) {
$this->logger->logWarning("No universal workflows found in {$genericRepo}");
return 0;
}
// Push to each template target
foreach (self::TEMPLATE_SYNC_TARGETS as $targetRepo) {
$targetParts = explode('/', $targetRepo);
$targetOrg = $targetParts[0];
$targetName = $targetParts[1];
foreach ($sourceFiles as $wfName => $source) {
$destPath = $source['path'];
try {
// Get existing file SHA for update
$existing = null;
try {
$existing = $this->adapter->getFileContents($targetOrg, $targetName, $destPath, 'dev');
} catch (Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
$existingSha = $existing['sha'] ?? null;
// Skip if content is identical
if ($existing !== null) {
$existingContent = $existing['content'] ?? '';
if (str_replace("\n", '', $existingContent) === str_replace("\n", '', $source['content'])) {
$this->logger->logInfo(" {$targetName}/{$wfName}: identical — skipped");
continue;
}
}
// Push update via Contents API
$this->adapter->createOrUpdateFile(
$targetOrg,
$targetName,
$destPath,
$source['content'],
"chore(ci): sync {$wfName} from Template-Generic [skip ci]",
$existingSha,
'dev'
);
$this->logger->logInfo(" {$targetName}/{$wfName}: updated");
$updated++;
} catch (Exception $e) {
$this->logger->logWarning(" {$targetName}/{$wfName}: failed — " . $e->getMessage());
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
}
$this->logger->logInfo("Universal workflow sync complete: {$updated} file(s) updated across templates");
return $updated;
}
private function getSharedWorkflows(string $platform, string $repoRoot): array
{
$wfDir = $this->adapter->getWorkflowDir();
// Determine which template repo to source from
$templateType = match (true) {
in_array($platform, ['dolibarr', 'platform']) => 'dolibarr',
in_array($platform, ['joomla', 'joomla']) => 'joomla',
str_starts_with($platform, 'client') => 'client',
default => 'generic',
};
// Clone template repo to tmp if not already cached
$templateRepo = self::TEMPLATE_REPOS[$templateType];
$templateRepo = self::TEMPLATE_REPOS[$platform] ?? self::TEMPLATE_REPOS['generic'];
$cacheDir = sys_get_temp_dir() . '/mokostandards-sync/' . basename($templateRepo);
if (!is_dir($cacheDir)) {
@@ -1114,8 +1228,12 @@ HCL;
}
}
// Read all .yml files from the template's .gitea/workflows/
$sourceDir = "{$cacheDir}/.gitea/workflows";
// Read all .yml files from the template's workflow directory
$sourceDir = "{$cacheDir}/.mokogitea/workflows";
// Fallback to legacy path if .mokogitea doesn't exist
if (!is_dir($sourceDir)) {
$sourceDir = "{$cacheDir}/.gitea/workflows";
}
$shared = [];
if (is_dir($sourceDir)) {
+2
View File
@@ -59,6 +59,8 @@ use RecursiveIteratorIterator;
/**
* Exception raised when security violations are detected
*
* @since 04.00.00
*/
class SecurityViolation extends Exception
{
+126 -30
View File
@@ -1,4 +1,28 @@
name: Deploy Dolibarr Module
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/deploy-module.yml
# VERSION: 02.00.00
# BRIEF: Deploy Dolibarr module to dev, demo, or live environments
#
# Secrets required:
# GA_TOKEN - Gitea API token for repo access
# DEPLOY_SSH_KEY - SSH private key for server access
# LIVE_TARGETS - JSON array of live instances (optional), e.g.:
# [{"host":"client1.example.com","user":"deploy",
# "mods_dir":"/path/MokoDoliMods",
# "custom_dir":"/path/htdocs/custom"}]
#
# Variables required:
# DEV_HOST, DEV_USER, DEV_MODS_DIR, DEV_CUSTOM_DIR
# DEMO_HOST, DEMO_USER, DEMO_MODS_DIR, DEMO_CUSTOM_DIR
name: "Dolibarr: Deploy Module"
on:
workflow_dispatch:
@@ -7,30 +31,26 @@ on:
description: 'Module repo name (e.g. MokoCRM, MokoDoliSign)'
required: true
server:
description: 'Target server'
description: 'Target environment'
required: true
default: 'dev'
type: choice
options:
- dev
- demo
- both
- live
- dev+demo
- all
env:
GITEA_URL: https://git.mokoconsulting.tech
ORG: MokoConsulting
DEV_HOST: waas.dev.mokoconsulting.tech
DEV_USER: mokoconsulting_dev
DEV_MODS_DIR: /home/mokoconsulting_dev/MokoDoliMods
DEV_CUSTOM_DIR: /home/mokoconsulting_dev/crm.dev.mokoconsulting.tech/htdocs/custom
DEMO_HOST: waas.demo.mokoconsulting.tech
DEMO_USER: mokoconsulting_demo
DEMO_MODS_DIR: /home/mokoconsulting_demo/MokoDoliMods
DEMO_CUSTOM_DIR: /home/mokoconsulting_demo/crm.demo.mokoconsulting.tech/htdocs/custom
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
ORG: ${{ vars.GITEA_ORG || 'MokoConsulting' }}
jobs:
deploy:
name: Deploy ${{ inputs.module_repo }} to ${{ inputs.server }}
runs-on: ubuntu-latest
steps:
- name: Validate module repo
run: |
@@ -39,11 +59,11 @@ jobs:
-H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${GITEA_URL}/api/v1/repos/${ORG}/${REPO}")
if [ "$STATUS" != "200" ]; then
echo "ERROR: ${ORG}/${REPO} not found"; exit 1
echo "::error::${ORG}/${REPO} not found (HTTP ${STATUS})"
exit 1
fi
echo "REPO=${REPO}" >> $GITHUB_ENV
# Derive symlink name
LINK_NAME=$(echo "$REPO" | sed 's/MokoDoli//;s/Moko//' | tr '[:upper:]' '[:lower:]')
[ "$REPO" = "MokoCRM" ] && LINK_NAME="mokocrm"
[ "$REPO" = "MokoDoliProjTemplate" ] && LINK_NAME="mokoprojtemplate"
@@ -56,27 +76,27 @@ jobs:
"${GITEA_URL}/api/v1/repos/${ORG}/${{ env.REPO }}/tags?limit=1")
TAG=$(echo "$TAGS" | jq -r '.[0].name // empty')
echo "TAG=${TAG}" >> $GITHUB_ENV
[ -n "$TAG" ] && echo "Deploying: $TAG" || echo "No tags deploying main"
[ -n "$TAG" ] && echo "Deploying: $TAG" || echo "No tags - deploying main"
- name: Deploy to dev
if: inputs.server == 'dev' || inputs.server == 'both'
if: inputs.server == 'dev' || inputs.server == 'dev+demo' || inputs.server == 'all'
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
host: ${{ vars.DEV_HOST }}
username: ${{ vars.DEV_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
REPO="${{ env.REPO }}"
LINK="${{ env.LINK_NAME }}"
TAG="${{ env.TAG }}"
MODS="${{ env.DEV_MODS_DIR }}"
CUSTOM="${{ env.DEV_CUSTOM_DIR }}"
MODS="${{ vars.DEV_MODS_DIR }}"
CUSTOM="${{ vars.DEV_CUSTOM_DIR }}"
mkdir -p "$MODS" && cd "$MODS"
if [ -d "$REPO" ]; then
cd "$REPO" && git fetch --tags origin
else
git clone "https://git.mokoconsulting.tech/${{ env.ORG }}/${REPO}.git"
git clone "${{ env.GITEA_URL }}/${{ env.ORG }}/${REPO}.git"
cd "$REPO"
fi
@@ -90,27 +110,27 @@ jobs:
cd "$CUSTOM"
[ -L "$LINK" ] || [ -d "$LINK" ] && rm -rf "$LINK"
ln -sf "$MODS/$REPO/src" "$LINK"
echo "OK: $LINK $MODS/$REPO/src (${TAG:-main})"
echo "OK: $LINK -> $MODS/$REPO/src (${TAG:-main})"
- name: Deploy to demo
if: inputs.server == 'demo' || inputs.server == 'both'
if: inputs.server == 'demo' || inputs.server == 'dev+demo' || inputs.server == 'all'
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEMO_HOST }}
username: ${{ env.DEMO_USER }}
host: ${{ vars.DEMO_HOST }}
username: ${{ vars.DEMO_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
REPO="${{ env.REPO }}"
LINK="${{ env.LINK_NAME }}"
TAG="${{ env.TAG }}"
MODS="${{ env.DEMO_MODS_DIR }}"
CUSTOM="${{ env.DEMO_CUSTOM_DIR }}"
MODS="${{ vars.DEMO_MODS_DIR }}"
CUSTOM="${{ vars.DEMO_CUSTOM_DIR }}"
mkdir -p "$MODS" && cd "$MODS"
if [ -d "$REPO" ]; then
cd "$REPO" && git fetch --tags origin
else
git clone "https://git.mokoconsulting.tech/${{ env.ORG }}/${REPO}.git"
git clone "${{ env.GITEA_URL }}/${{ env.ORG }}/${REPO}.git"
cd "$REPO"
fi
@@ -124,4 +144,80 @@ jobs:
cd "$CUSTOM"
[ -L "$LINK" ] || [ -d "$LINK" ] && rm -rf "$LINK"
ln -sf "$MODS/$REPO/src" "$LINK"
echo "OK: $LINK $MODS/$REPO/src (${TAG:-main})"
echo "OK: $LINK -> $MODS/$REPO/src (${TAG:-main})"
- name: Deploy to live
if: inputs.server == 'live' || inputs.server == 'all'
env:
LIVE_TARGETS: ${{ secrets.LIVE_TARGETS }}
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
if [ -z "$LIVE_TARGETS" ] || [ "$LIVE_TARGETS" = "null" ]; then
echo "::error::LIVE_TARGETS secret is not configured."
echo "Set it to a JSON array of target objects."
exit 1
fi
COUNT=$(echo "$LIVE_TARGETS" | jq 'length')
echo "Deploying to ${COUNT} live instance(s)..."
echo "$DEPLOY_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
FAILED=0
for i in $(seq 0 $((COUNT - 1))); do
HOST=$(echo "$LIVE_TARGETS" | jq -r ".[$i].host")
USER=$(echo "$LIVE_TARGETS" | jq -r ".[$i].user")
MODS=$(echo "$LIVE_TARGETS" | jq -r ".[$i].mods_dir")
CUSTOM=$(echo "$LIVE_TARGETS" | jq -r ".[$i].custom_dir")
PORT=$(echo "$LIVE_TARGETS" | jq -r ".[$i].port // 22")
LABEL=$(echo "$LIVE_TARGETS" | jq -r ".[$i].label // empty")
[ -z "$LABEL" ] && LABEL="$HOST"
echo ""
echo "=== Instance $((i+1))/${COUNT}: ${LABEL} (${USER}@${HOST}:${PORT}) ==="
ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 \
-i /tmp/deploy_key -p "$PORT" "${USER}@${HOST}" \
"REPO='${{ env.REPO }}' LINK='${{ env.LINK_NAME }}' TAG='${{ env.TAG }}' MODS='${MODS}' CUSTOM='${CUSTOM}' GITEA_URL='${{ env.GITEA_URL }}' ORG='${{ env.ORG }}' bash" <<'REMOTE_SCRIPT' || { echo "::warning::Failed: ${LABEL}"; FAILED=$((FAILED+1)); continue; }
mkdir -p "$MODS" && cd "$MODS"
if [ -d "$REPO" ]; then
cd "$REPO" && git fetch --tags origin
else
git clone "${GITEA_URL}/${ORG}/${REPO}.git"
cd "$REPO"
fi
if [ -n "$TAG" ]; then
git checkout "$TAG" --quiet
else
git checkout main --quiet
git pull --ff-only origin main --quiet
fi
cd "$CUSTOM"
[ -L "$LINK" ] || [ -d "$LINK" ] && rm -rf "$LINK"
ln -sf "$MODS/$REPO/src" "$LINK"
echo "OK: $LINK -> $MODS/$REPO/src (${TAG:-main})"
REMOTE_SCRIPT
done
rm -f /tmp/deploy_key
if [ "$FAILED" -gt 0 ]; then
echo "::error::${FAILED} of ${COUNT} live deployment(s) failed"
exit 1
fi
echo "All ${COUNT} live instance(s) deployed successfully."
- name: Summary
if: always()
run: |
echo "## Deploy: ${{ env.REPO }} -> ${{ inputs.server }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Module | \`${{ env.REPO }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Symlink | \`${{ env.LINK_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${{ env.TAG || 'main' }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Target | \`${{ inputs.server }}\` |" >> $GITHUB_STEP_SUMMARY
+8
View File
@@ -33,6 +33,14 @@ require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\{AuditLogger, CliFramework, MetricsCollector, PluginFactory};
/**
* Repository Health Checker
*
* Validates repository structure, standards compliance, and configuration
* against MokoStandards definitions. Produces a health score and report.
*
* @since 04.00.00
*/
class RepoHealthChecker extends CliFramework
{
private AuditLogger $logger;
+8
View File
@@ -19,6 +19,14 @@ require_once __DIR__ . '/../vendor/autoload.php';
use MokoEnterprise\CliFramework;
/**
* Wiki Health Checker
*
* Validates Gitea wiki structure and content for a repository,
* checking for required pages, broken links, and formatting issues.
*
* @since 04.00.00
*/
class CheckWikiHealth extends CliFramework
{
protected function configure(): void