Files
mokoplatform/release/generate_dolibarr_version_txt.php
Jonathan Miller 11eb1e2649
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
chore(release): bump to 09.23.00 — plugin commands, audit query, version fix
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 14:18:26 -05:00

339 lines
12 KiB
PHP

#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Release
* INGROUP: MokoPlatform.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\CliFramework;
use MokoEnterprise\{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 = \MokoEnterprise\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());