diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index 21b9bd1..4a2beb4 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -4,11 +4,15 @@ Auto-generated by cleanup script. See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home --> - + moko-platform MokoConsulting Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories + 09.01.00 GNU General Public License v3 diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index efb2537..3a815fa 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -27,12 +27,9 @@ name: "Universal: Build & Release" on: pull_request: - types: [closed] + types: [opened, closed] branches: - main - paths: - - 'src/**' - - 'htdocs/**' workflow_dispatch: env: @@ -45,6 +42,60 @@ 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 + + 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 }}" + + - 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 @@ -100,9 +151,30 @@ jobs: echo "skip=false" >> "$GITHUB_OUTPUT" echo "branch=main" >> "$GITHUB_OUTPUT" + # -- 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: Bump version" id: bump - if: steps.version.outputs.skip != 'true' + 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) @@ -320,10 +392,26 @@ 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.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 }}" @@ -388,7 +476,8 @@ jobs: # -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------ - name: "Step 8: Build package and update checksum" 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 }}" @@ -593,11 +682,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' diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 0980e61..2f70d8c 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -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 @@ -71,20 +78,12 @@ jobs: - name: Detect platform id: platform run: | - PLATFORM=$(sed -n 's/.*\([^<]*\)<\/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 '/dev/null | head -1) - [ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '/dev/null | head -1) - [ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) - MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) - echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" - echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" + 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" ;; @@ -97,16 +96,13 @@ jobs: php ${MOKO_CLI}/version_bump.php --path . 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 +113,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>.*/\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" @@ -268,54 +244,17 @@ jobs: - 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 }}" 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 - - 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>).)*?" . 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}", $block); - if (strpos($block, "") !== false) { - $block = preg_replace("/[^<]*<\/sha256>/", "{$sha256}", $block); - } else { - $block = str_replace("", "\n {$sha256}", $block); - } - $block = preg_replace("/(]*>)[^<]*(<\/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}" # Commit and push if ! git diff --quiet updates.xml 2>/dev/null; then diff --git a/CLAUDE.md b/CLAUDE.md index 31d8ad8..c8c42c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 9fd41d3..3ad5cfe 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ DEFGROUP: MokoStandards.Root INGROUP: MokoStandards REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform PATH: /README.md +VERSION: 09.01.00 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) diff --git a/automation/bulk_sync.php b/automation/bulk_sync.php index 3e4e4e4..76e4099 100755 --- a/automation/bulk_sync.php +++ b/automation/bulk_sync.php @@ -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); diff --git a/bin/moko b/bin/moko index 9aea292..4a4c33b 100644 --- a/bin/moko +++ b/bin/moko @@ -1,5 +1,6 @@ #!/usr/bin/env php * @@ -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', diff --git a/cli/joomla_release.php b/cli/joomla_release.php index 161d9de..5d75e2b 100644 --- a/cli/joomla_release.php +++ b/cli/joomla_release.php @@ -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'; diff --git a/cli/manifest_element.php b/cli/manifest_element.php new file mode 100644 index 0000000..a2af665 --- /dev/null +++ b/cli/manifest_element.php @@ -0,0 +1,235 @@ +#!/usr/bin/env php + + * + * 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>/', $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, ', plugin= attribute, , or filename + if (preg_match('/([^<]+)<\/element>/', $xml, $em)) { + $extElement = $em[1]; + } + if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { + $extElement = $pm[1]; + } + if ($extType === 'package' && preg_match('/([^<]+)<\/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>/', $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); diff --git a/cli/release_cascade.php b/cli/release_cascade.php index 9ea510a..20e57f8 100644 --- a/cli/release_cascade.php +++ b/cli/release_cascade.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * 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"; diff --git a/cli/release_create.php b/cli/release_create.php new file mode 100644 index 0000000..0c4be56 --- /dev/null +++ b/cli/release_create.php @@ -0,0 +1,328 @@ +#!/usr/bin/env php + + * + * 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|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>/', $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, ', plugin= attribute, , or filename + if (preg_match('/([^<]+)<\/element>/', $xml, $em)) { + $extElement = $em[1]; + } + if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) { + $extElement = $pm2[1]; + } + if ($extType === 'package' && preg_match('/([^<]+)<\/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>/', $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); diff --git a/cli/release_mirror.php b/cli/release_mirror.php new file mode 100644 index 0000000..459243b --- /dev/null +++ b/cli/release_mirror.php @@ -0,0 +1,300 @@ +#!/usr/bin/env php + + * + * 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|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|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); diff --git a/cli/release_package.php b/cli/release_package.php new file mode 100644 index 0000000..3262def --- /dev/null +++ b/cli/release_package.php @@ -0,0 +1,518 @@ +#!/usr/bin/env php + + * + * 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|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; +} + +// ── 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, ', plugin= attribute, , or filename + if (preg_match('/([^<]+)<\/element>/', $xml, $em)) { + $extElement = $em[1]; + } + if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { + $extElement = $pm[1]; + } + // For packages: prefer over filename + if ($extType === 'package' && preg_match('/([^<]+)<\/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; +if (is_dir("{$root}/src")) { + $sourceDir = "{$root}/src"; +} elseif (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 */ +$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 $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 $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)); + } + } + + $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"; + +// ── 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); diff --git a/cli/release_promote.php b/cli/release_promote.php new file mode 100644 index 0000000..8e24cde --- /dev/null +++ b/cli/release_promote.php @@ -0,0 +1,316 @@ +#!/usr/bin/env php + + * + * 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 --to --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|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, ' $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); diff --git a/cli/release_validate.php b/cli/release_validate.php index 7a37cac..e758aa2 100644 --- a/cli/release_validate.php +++ b/cli/release_validate.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * 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>/', $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 */ $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, '([^<]+)<\/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 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, '([^<]+)<\/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 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('/' . 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('/' . 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); diff --git a/cli/updates_xml_build.php b/cli/updates_xml_build.php index 025945c..0dcd587 100644 --- a/cli/updates_xml_build.php +++ b/cli/updates_xml_build.php @@ -217,12 +217,12 @@ $releaseTagMap = [ $primarySuffix = $stabilitySuffixMap[$stability] ?? ''; $primaryVersion = $version . $primarySuffix; -// Build client tag — only needed for templates and modules (site vs admin). -// Packages and components don't use client; plugins use folder instead. +// Build client tag — Joomla requires site to match updates +// to installed extensions. Without it, extension_id=0 in #__updates. $clientTag = ''; if (!empty($extClient)) { $clientTag = " {$extClient}"; -} elseif (in_array($extType, ['template', 'module'])) { +} else { $clientTag = ' site'; } @@ -339,6 +339,8 @@ if (file_exists($dest)) { 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 = ''; diff --git a/cli/version_bump.php b/cli/version_bump.php index 1f91ea9..661df0c 100644 --- a/cli/version_bump.php +++ b/cli/version_bump.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -17,9 +18,15 @@ declare(strict_types=1); $path = '.'; $type = 'patch'; // patch | minor | major foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--minor') $type = 'minor'; - if ($arg === '--major') $type = 'major'; + if ($arg === '--path' && isset($argv[$i + 1])) { + $path = $argv[$i + 1]; + } + if ($arg === '--minor') { + $type = 'minor'; + } + if ($arg === '--major') { + $type = 'major'; + } } $root = realpath($path) ?: $path; @@ -95,12 +102,25 @@ $patch = (int)$parts[3]; $old = sprintf('%02d.%02d.%02d', $major, $minor, $patch); switch ($type) { - case 'major': $major++; $minor = 0; $patch = 0; break; - case 'minor': $minor++; $patch = 0; break; + case 'major': + $major++; + $minor = 0; + $patch = 0; + break; + case 'minor': + $minor++; + $patch = 0; + break; default: $patch++; - if ($patch > 99) { $minor++; $patch = 0; } - if ($minor > 99) { $major++; $minor = 0; } + if ($patch > 99) { + $minor++; + $patch = 0; + } + if ($minor > 99) { + $major++; + $minor = 0; + } break; } @@ -108,12 +128,34 @@ $new = sprintf('%02d.%02d.%02d', $major, $minor, $patch); // -- Update .mokogitea/manifest.xml (canonical target) -- if (file_exists($mokoManifest) && !empty($mokoContent)) { - $updated = preg_replace( - '|\d{2}\.\d{2}\.\d{2}|', - "{$new}", - $mokoContent, - 1 - ); + if (preg_match('|\d{2}\.\d{2}\.\d{2}|', $mokoContent)) { + // Replace existing version tag + $updated = preg_replace( + '|\d{2}\.\d{2}\.\d{2}|', + "{$new}", + $mokoContent, + 1 + ); + } else { + // Insert before (per schema order) or as last child of + if (strpos($mokoContent, '{$new}\$1", + $mokoContent, + 1 + ); + } elseif (strpos($mokoContent, '') !== false) { + $updated = preg_replace( + '|()|', + " {$new}\n \$1", + $mokoContent, + 1 + ); + } else { + $updated = $mokoContent; + } + } file_put_contents($mokoManifest, $updated); } @@ -128,5 +170,55 @@ if (file_exists($readme) && !empty($readmeContent)) { file_put_contents($readme, $updated); } -echo "{$old} -> {$new}\n"; +// ── Update manifest XML files ──────────────────────────────────────────────── +foreach ($manifestFiles as $xmlFile) { + $xmlContent = file_get_contents($xmlFile); + if (strpos($xmlContent, '') === false) { + continue; + } + $updatedXml = preg_replace( + '|\d{2}\.\d{2}\.\d{2}(?:-[a-z]+)?|', + "{$new}", + $xmlContent + ); + if ($updatedXml !== $xmlContent) { + file_put_contents($xmlFile, $updatedXml); + } +} + +// ── Update Dolibarr mod*.class.php ─────────────────────────────────────────── +$modFiles = array_merge( + glob("{$root}/src/core/modules/mod*.class.php") ?: [], + glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [] +); +foreach ($modFiles as $modFile) { + $modContent = file_get_contents($modFile); + if (strpos($modContent, 'extends DolibarrModules') === false) { + continue; + } + $updatedMod = preg_replace( + '/(\$this->version\s*=\s*)[\'"][^\'"]*[\'"]/', + "\${1}'{$new}'", + $modContent + ); + if ($updatedMod !== $modContent) { + file_put_contents($modFile, $updatedMod); + } +} + +// ── Update composer.json ───────────────────────────────────────────────────── +$composerFile = "{$root}/composer.json"; +if (file_exists($composerFile)) { + $composerContent = file_get_contents($composerFile); + $updatedComposer = preg_replace( + '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(")/m', + '${1}' . $new . '${2}', + $composerContent + ); + if ($updatedComposer !== $composerContent) { + file_put_contents($composerFile, $updatedComposer); + } +} + +echo "{$old} → {$new}\n"; exit(0); diff --git a/cli/version_read.php b/cli/version_read.php index 78f24c5..c31a4f8 100644 --- a/cli/version_read.php +++ b/cli/version_read.php @@ -89,5 +89,29 @@ if ($version === null) { exit(1); } +// -- Backfill: if manifest.xml exists but lacks , insert it -- +if ($mokoVersion === null && file_exists($mokoManifest)) { + $content = file_get_contents($mokoManifest); + if (!preg_match('|\d{2}\.\d{2}\.\d{2}|', $content)) { + if (strpos($content, '{$version}\$1", + $content, + 1 + ); + } elseif (strpos($content, '') !== false) { + $content = preg_replace( + '|()|', + " {$version}\n \$1", + $content, + 1 + ); + } + file_put_contents($mokoManifest, $content); + fwrite(STDERR, "Backfilled manifest.xml with version {$version}\n"); + } +} + echo $version . "\n"; exit(0); diff --git a/cli/version_reset_dev.php b/cli/version_reset_dev.php new file mode 100644 index 0000000..79712c8 --- /dev/null +++ b/cli/version_reset_dev.php @@ -0,0 +1,319 @@ +#!/usr/bin/env php + + * + * 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|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; +} diff --git a/composer.json b/composer.json index f430513..02f9725 100644 --- a/composer.json +++ b/composer.json @@ -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": [ { diff --git a/definitions/manifest-schema.xsd b/definitions/manifest-schema.xsd index c8ca0c2..60e3d3c 100644 --- a/definitions/manifest-schema.xsd +++ b/definitions/manifest-schema.xsd @@ -3,7 +3,7 @@ Copyright (C) 2026 Moko Consulting SPDX-License-Identifier: GPL-3.0-or-later - MokoStandards Manifest Schema v1.0 + MokoStandards Manifest Schema v09.01.00 Defines the structure of .mokogitea/manifest.xml Validate: xmllint - -schema definitions/manifest-schema.xsd .mokogitea/manifest.xml @@ -11,7 +11,8 @@ + elementFormDefault="qualified" + version="09.01.00"> diff --git a/lib/Enterprise/AuditLogger.php b/lib/Enterprise/AuditLogger.php index 668eef0..85883a2 100644 --- a/lib/Enterprise/AuditLogger.php +++ b/lib/Enterprise/AuditLogger.php @@ -58,6 +58,8 @@ use RuntimeException; * $transaction->logSecurityEvent('file_modified', ['file' => 'README.md']); * $transaction->end(); * ``` + * + * @since 04.00.00 */ class AuditLogger { diff --git a/lib/Enterprise/ConfigValidator.php b/lib/Enterprise/ConfigValidator.php index f78c5a7..7799951 100644 --- a/lib/Enterprise/ConfigValidator.php +++ b/lib/Enterprise/ConfigValidator.php @@ -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 */ diff --git a/lib/Enterprise/DefinitionParser.php b/lib/Enterprise/DefinitionParser.php index 009e471..bebbcf2 100644 --- a/lib/Enterprise/DefinitionParser.php +++ b/lib/Enterprise/DefinitionParser.php @@ -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 { diff --git a/lib/Enterprise/MetricsCollector.php b/lib/Enterprise/MetricsCollector.php index 70fb052..fb25188 100644 --- a/lib/Enterprise/MetricsCollector.php +++ b/lib/Enterprise/MetricsCollector.php @@ -59,6 +59,8 @@ use DateTimeZone; /** * Timer class for timing operations + * + * @since 04.00.00 */ class MetricsTimer { diff --git a/lib/Enterprise/PlatformAdapterFactory.php b/lib/Enterprise/PlatformAdapterFactory.php index 88b9aa1..cb801f4 100644 --- a/lib/Enterprise/PlatformAdapterFactory.php +++ b/lib/Enterprise/PlatformAdapterFactory.php @@ -35,6 +35,8 @@ use RuntimeException; * * @package MokoStandards\Enterprise * @version 04.06.10 + * + * @since 04.00.00 */ class PlatformAdapterFactory { diff --git a/lib/Enterprise/ProjectConfigValidator.php b/lib/Enterprise/ProjectConfigValidator.php index 8904161..4cd8a1a 100644 --- a/lib/Enterprise/ProjectConfigValidator.php +++ b/lib/Enterprise/ProjectConfigValidator.php @@ -24,6 +24,8 @@ namespace MokoEnterprise; * * Enterprise library for validating project configurations against * project type templates and standards. + * + * @since 04.00.00 */ class ProjectConfigValidator { diff --git a/lib/Enterprise/ProjectTypeDetector.php b/lib/Enterprise/ProjectTypeDetector.php index d99e977..23447ad 100644 --- a/lib/Enterprise/ProjectTypeDetector.php +++ b/lib/Enterprise/ProjectTypeDetector.php @@ -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 { diff --git a/lib/Enterprise/RepositoryHealthChecker.php b/lib/Enterprise/RepositoryHealthChecker.php index bb85473..ebb0be9 100644 --- a/lib/Enterprise/RepositoryHealthChecker.php +++ b/lib/Enterprise/RepositoryHealthChecker.php @@ -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 { diff --git a/lib/Enterprise/RepositorySynchronizer.php b/lib/Enterprise/RepositorySynchronizer.php index 195fe21..286e08c 100644 --- a/lib/Enterprise/RepositorySynchronizer.php +++ b/lib/Enterprise/RepositorySynchronizer.php @@ -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)) { diff --git a/lib/Enterprise/SecurityValidator.php b/lib/Enterprise/SecurityValidator.php index 285d1e4..c26f4da 100644 --- a/lib/Enterprise/SecurityValidator.php +++ b/lib/Enterprise/SecurityValidator.php @@ -59,6 +59,8 @@ use RecursiveIteratorIterator; /** * Exception raised when security violations are detected + * + * @since 04.00.00 */ class SecurityViolation extends Exception { diff --git a/validate/check_repo_health.php b/validate/check_repo_health.php index ba0bb0a..8b47874 100755 --- a/validate/check_repo_health.php +++ b/validate/check_repo_health.php @@ -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; diff --git a/validate/check_wiki_health.php b/validate/check_wiki_health.php index ad7cd41..1ceb459 100644 --- a/validate/check_wiki_health.php +++ b/validate/check_wiki_health.php @@ -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