Files
moko-platform/release/generate_dolibarr_version_txt.php
Jonathan Miller 1d87be7d5e
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
fix: standardize file headers — REPO rename, SPDX case, missing fields
- Update REPO: from MokoStandards-API to moko-platform in 125 files
- Fix wrong org path (mokoconsulting-tech → MokoConsulting) in 10 files
- Fix SPDX-LICENSE-IDENTIFIER case in 2 template files
- Add missing REPO: field to 3 files

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 17:01:17 -05:00

351 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Release
* INGROUP: MokoStandards.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* 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 MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config};
class GenerateDolibarrVersionTxt extends CLIApp
{
public const VERSION = '04.06.00';
private ?ApiClient $api = null;
private AuditLogger $logger;
protected function setupArguments(): array
{
return [
'repo:' => 'GitHub repo (org/repo) for remote mode.',
'tag:' => 'Git tag for this release, e.g. v1.2.0 (required)',
'output:' => 'Local output path for version.txt (default: ./version.txt)',
'makefile:' => 'Local path to Makefile (default: ./Makefile)',
'inject-urlversion'=> 'Inject / refresh $this->url_last_version in the Dolibarr module descriptor class.',
];
}
protected function run(): int
{
$this->log('📦 Dolibarr version.txt — release updater v' . self::VERSION, 'INFO');
$this->logger = new AuditLogger('dolibarr_version_txt');
$tag = $this->getOption('tag', '');
if (empty($tag)) {
$this->log('❌ --tag is required (e.g. --tag=v1.2.0)', 'ERROR');
return 1;
}
$version = $this->sanitizeVersion(ltrim($tag, 'vV'));
if (strlen($version) === 0 || strlen($version) > 29) {
$this->log("❌ Sanitized version '{$version}' is empty or > 29 characters", 'ERROR');
return 1;
}
$repoArg = $this->getOption('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('❌ --repo must be in org/repo format', 'ERROR');
return 1;
}
[$org, $repo] = explode('/', $repoArg, 2);
$repoData = $this->api->get("/repos/{$org}/{$repo}");
$defaultBranch = $repoData['default_branch'] ?? 'main';
if ($this->dryRun) {
$this->log("(dry-run) version.txt content: {$version}", 'INFO');
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("️ Updating existing version.txt", 'INFO');
} else {
$this->log("️ Creating new version.txt", 'INFO');
}
try {
$this->api->put("/repos/{$org}/{$repo}/contents/version.txt", $payload);
$this->log("✅ version.txt committed to {$defaultBranch}{$version}", 'INFO');
} catch (\Exception $e) {
$this->log('❌ Failed to commit version.txt: ' . $e->getMessage(), 'ERROR');
return 1;
}
if ($this->hasOption('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->getOption('output', './version.txt');
if ($this->dryRun) {
$this->log("(dry-run) version.txt content: {$version}", 'INFO');
return 0;
}
$action = file_exists($outputPath) ? 'Updated' : 'Created';
if (file_put_contents($outputPath, $version) === false) {
$this->log("❌ Failed to write: {$outputPath}", 'ERROR');
return 1;
}
$this->log("✅ {$action} {$outputPath}{$version}", 'INFO');
if ($this->hasOption('inject-urlversion')) {
$ghRepo = getenv('GITHUB_REPOSITORY') ?: '';
if (!str_contains($ghRepo, '/')) {
$this->log('⚠️ GITHUB_REPOSITORY not set — cannot inject url_last_version', 'WARN');
} 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).
* These follow the naming pattern: mod<Name>.class.php and live under
* htdocs/<module>/core/modules/ or core/modules/ at the repo root.
*
* @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(" ✅ url_last_version → {$path}", 'INFO');
} else {
$this->log(" ️ url_last_version already correct in {$path}", 'INFO');
}
}
/**
* Inject or update $this->url_last_version in a Dolibarr module descriptor.
* - If a line with url_last_version exists (commented or uncommented), replace it.
* - Otherwise, insert after the $this->version assignment.
*
* 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(" ️ url_last_version already correct in {$path}", 'INFO');
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(" ✅ url_last_version → {$path}", 'INFO');
}
} catch (\Exception $e) {
$this->log('⚠️ url_last_version injection failed: ' . $e->getMessage(), 'WARN');
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Strip any characters that Dolibarr would strip from the version string.
* DolibarrModules::checkForUpdate() applies: /[^a-zA-Z0-9_\.\-]+/
*/
private function sanitizeVersion(string $version): string
{
return preg_replace('/[^a-zA-Z0-9_.\-]+/', '', $version) ?? '';
}
private function initApi(): bool
{
$config = Config::load();
try {
$this->adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
return true;
} catch (\Exception $e) {
$this->log('❌ API init failed: ' . $e->getMessage(), 'ERROR');
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;
}
}
}
// Execute if run directly
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$app = new GenerateDolibarrVersionTxt(
'generate-dolibarr-version-txt',
'Create or update version.txt for Dolibarr module update checks',
GenerateDolibarrVersionTxt::VERSION
);
exit($app->execute());
}