#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoPlatform.Release * INGROUP: MokoPlatform.Scripts * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * PATH: /release/generate_dolibarr_version_txt.php * BRIEF: Create or update version.txt on Dolibarr module release * * Dolibarr Update Server helper. On each release it: * 1. Creates or overwrites version.txt at the repo root with just the * bare version string (e.g. "1.4.2") — no whitespace, under 30 chars. * 2. Optionally injects / refreshes the $this->url_last_version property * in the Dolibarr module descriptor class (--inject-urlversion). * * Dolibarr's DolibarrModules::checkForUpdate() fetches the URL stored in * $this->url_last_version and compares the plain-text response to the * installed version. The response MUST be: * - Under 30 characters total * - Only alphanumeric chars, dots, dashes, underscores (others stripped) * - No newline or trailing whitespace * * Runs in two modes: * Local — reads/writes version.txt on disk (GitHub Actions release workflow). * Remote — reads/commits version.txt via GitHub API. * * Usage (local): * php generate_dolibarr_version_txt.php \ * --tag=v1.2.0 \ * [--output=./version.txt] \ * [--inject-urlversion] * * Usage (remote): * php generate_dolibarr_version_txt.php \ * --repo=mokoconsulting-tech/MyModule \ * --tag=v1.2.0 \ * [--inject-urlversion] */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoCli\CliFramework; use MokoCli\{ApiClient, AuditLogger, Config}; class GenerateDolibarrVersionTxtCli extends CliFramework { public const VERSION = '09.23.00'; private ?ApiClient $api = null; private AuditLogger $logger; protected function configure(): void { $this->setDescription('Create or update version.txt for Dolibarr module update checks'); $this->addArgument('--repo', 'GitHub repo (org/repo) for remote mode', ''); $this->addArgument('--tag', 'Git tag for this release, e.g. v1.2.0 (required)', ''); $this->addArgument('--output', 'Local output path for version.txt (default: ./version.txt)', './version.txt'); $this->addArgument('--makefile', 'Local path to Makefile (default: ./Makefile)', './Makefile'); $this->addArgument('--inject-urlversion', 'Inject / refresh $this->url_last_version in the Dolibarr module descriptor class', false); } protected function run(): int { $this->log('INFO', 'Dolibarr version.txt — release updater v' . self::VERSION); $this->logger = new AuditLogger('dolibarr_version_txt'); $tag = $this->getArgument('--tag'); if (empty($tag)) { $this->log('ERROR', '--tag is required (e.g. --tag=v1.2.0)'); return 1; } $version = $this->sanitizeVersion(ltrim($tag, 'vV')); if (strlen($version) === 0 || strlen($version) > 29) { $this->log('ERROR', "Sanitized version '{$version}' is empty or > 29 characters"); return 1; } $repoArg = $this->getArgument('--repo'); return !empty($repoArg) ? $this->runRemote($repoArg, $tag, $version) : $this->runLocal($tag, $version); } // ------------------------------------------------------------------------- // Remote mode // ------------------------------------------------------------------------- private function runRemote(string $repoArg, string $tag, string $version): int { if (!$this->initApi()) { return 1; } if (!str_contains($repoArg, '/')) { $this->log('ERROR', '--repo must be in org/repo format'); return 1; } [$org, $repo] = explode('/', $repoArg, 2); $repoData = $this->api->get("/repos/{$org}/{$repo}"); $defaultBranch = $repoData['default_branch'] ?? 'main'; if ($this->dryRun) { $this->log('INFO', "(dry-run) version.txt content: {$version}"); return 0; } $sha = $this->fetchRemoteFileSha($org, $repo, 'version.txt'); $payload = [ 'message' => "chore(release): update version.txt to {$version} for {$tag}", 'content' => base64_encode($version), 'branch' => $defaultBranch, ]; if ($sha !== null) { $payload['sha'] = $sha; $this->log('INFO', 'Updating existing version.txt'); } else { $this->log('INFO', 'Creating new version.txt'); } try { $this->api->put("/repos/{$org}/{$repo}/contents/version.txt", $payload); $this->log('INFO', "version.txt committed to {$defaultBranch} -> {$version}"); } catch (\Exception $e) { $this->log('ERROR', 'Failed to commit version.txt: ' . $e->getMessage()); return 1; } if ($this->getArgument('--inject-urlversion')) { $rawUrl = "https://raw.githubusercontent.com/{$org}/{$repo}/{$defaultBranch}/version.txt"; $this->injectUrlVersionRemote($org, $repo, $defaultBranch, $rawUrl); } return 0; } // ------------------------------------------------------------------------- // Local mode // ------------------------------------------------------------------------- private function runLocal(string $tag, string $version): int { $outputPath = $this->getArgument('--output'); if ($this->dryRun) { $this->log('INFO', "(dry-run) version.txt content: {$version}"); return 0; } $action = file_exists($outputPath) ? 'Updated' : 'Created'; if (file_put_contents($outputPath, $version) === false) { $this->log('ERROR', "Failed to write: {$outputPath}"); return 1; } $this->log('INFO', "{$action} {$outputPath} -> {$version}"); if ($this->getArgument('--inject-urlversion')) { $ghRepo = getenv('GITHUB_REPOSITORY') ?: ''; if (!str_contains($ghRepo, '/')) { $this->warning('GITHUB_REPOSITORY not set — cannot inject url_last_version'); } else { [$org, $repo] = explode('/', $ghRepo, 2); $rawUrl = "https://raw.githubusercontent.com/{$org}/{$repo}/main/version.txt"; foreach ($this->findModuleDescriptors('.') as $path) { $this->injectUrlVersionIntoFile($path, $rawUrl); } } } return 0; } // ------------------------------------------------------------------------- // url_last_version injection into module descriptor class // ------------------------------------------------------------------------- /** * Find all Dolibarr module descriptor files (modXxx.class.php). * * @return string[] */ private function findModuleDescriptors(string $dir): array { $found = []; $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir)); foreach ($iterator as $file) { if (!$file->isFile()) { continue; } $basename = $file->getBasename(); if (preg_match('/^mod[A-Z][^.]+\.class\.php$/', $basename)) { $content = file_get_contents((string) $file) ?: ''; // Must extend DolibarrModules to be a real module descriptor if (str_contains($content, 'DolibarrModules')) { $found[] = (string) $file; } } } return $found; } private function injectUrlVersionIntoFile(string $path, string $rawUrl): void { $content = file_get_contents($path) ?: ''; $updated = $this->injectUrlVersionIntoPhp($content, $rawUrl); if ($updated !== null) { file_put_contents($path, $updated); $this->log('INFO', " url_last_version -> {$path}"); } else { $this->log('INFO', " url_last_version already correct in {$path}"); } } /** * Inject or update $this->url_last_version in a Dolibarr module descriptor. * * Returns null if no change is needed. */ private function injectUrlVersionIntoPhp(string $content, string $rawUrl): ?string { $newLine = "\t\t\$this->url_last_version = '{$rawUrl}';"; // Replace any existing url_last_version line (commented or not) if (preg_match('/^[ \t]*\/?\/?[ \t]*\$this->url_last_version\s*=.+$/m', $content)) { $updated = preg_replace( '/^[ \t]*\/?\/?[ \t]*\$this->url_last_version\s*=.+$/m', $newLine, $content ); return ($updated !== $content) ? $updated : null; } // Insert after $this->version assignment $updated = preg_replace( '/^([ \t]*\$this->version\s*=.+)$/m', "$1\n{$newLine}", $content, 1 ); return ($updated !== null && $updated !== $content) ? $updated : null; } private function injectUrlVersionRemote(string $org, string $repo, string $branch, string $rawUrl): void { try { $tree = $this->api->get("/repos/{$org}/{$repo}/git/trees/{$branch}", ['recursive' => '1']); foreach ($tree['tree'] ?? [] as $node) { if ($node['type'] !== 'blob') { continue; } $path = $node['path'] ?? ''; if (!preg_match('/mod[A-Z][^\/]+\.class\.php$/', $path)) { continue; } $content = $this->fetchRemoteFile($org, $repo, $path); if ($content === null || !str_contains($content, 'DolibarrModules')) { continue; } $updated = $this->injectUrlVersionIntoPhp($content, $rawUrl); if ($updated === null) { $this->log('INFO', " url_last_version already correct in {$path}"); continue; } $sha = $this->fetchRemoteFileSha($org, $repo, $path); $payload = [ 'message' => 'chore(release): inject url_last_version in module descriptor', 'content' => base64_encode($updated), 'branch' => $branch, ]; if ($sha !== null) { $payload['sha'] = $sha; } $this->api->put("/repos/{$org}/{$repo}/contents/{$path}", $payload); $this->log('INFO', " url_last_version -> {$path}"); } } catch (\Exception $e) { $this->warning('url_last_version injection failed: ' . $e->getMessage()); } } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Strip any characters that Dolibarr would strip from the version string. */ private function sanitizeVersion(string $version): string { return preg_replace('/[^a-zA-Z0-9_.\-]+/', '', $version) ?? ''; } private function initApi(): bool { $config = Config::load(); try { $adapter = \MokoCli\PlatformAdapterFactory::create($config); $this->api = $adapter->getApiClient(); return true; } catch (\Exception $e) { $this->log('ERROR', 'API init failed: ' . $e->getMessage()); return false; } } private function fetchRemoteFile(string $org, string $repo, string $path): ?string { try { $r = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}"); return base64_decode(str_replace(["\n", "\r"], '', $r['content'] ?? '')) ?: null; } catch (\Exception $e) { return null; } } private function fetchRemoteFileSha(string $org, string $repo, string $path): ?string { try { return $this->api->get("/repos/{$org}/{$repo}/contents/{$path}")['sha'] ?? null; } catch (\Exception $e) { return null; } } } $app = new GenerateDolibarrVersionTxtCli(); exit($app->execute());