1d87be7d5e
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
- 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>
486 lines
17 KiB
PHP
486 lines
17 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: MokoStandards.Scripts.Maintenance
|
|
* INGROUP: MokoStandards
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
* PATH: /maintenance/update_version_from_readme.php
|
|
* BRIEF: Reads VERSION from README.md FILE INFORMATION block and propagates it to all badges and FILE INFORMATION headers
|
|
* NOTE: README.md is the single source of truth for the repository version.
|
|
* Version format is zero-padded semver: XX.YY.ZZ (e.g. 04.00.04). All regex patterns
|
|
* in this script enforce exactly two digits per component by design.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../vendor/autoload.php';
|
|
|
|
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework};
|
|
|
|
/**
|
|
* Propagates the version from README.md FILE INFORMATION block to every
|
|
* badge and FILE INFORMATION VERSION field in the repository.
|
|
*
|
|
* Sources updated:
|
|
* - Markdown badge: []
|
|
* - Markdown header: VERSION: OLD (inside <!-- --> comment blocks)
|
|
* - PHP header: * VERSION: OLD (inside block comments)
|
|
* - YAML/Shell header:# VERSION: OLD
|
|
* - composer.json: "version": "OLD"
|
|
*/
|
|
class UpdateVersionFromReadme extends CliFramework
|
|
{
|
|
private AuditLogger $logger;
|
|
private ?ApiClient $apiClient = null;
|
|
|
|
/** Files updated during this run */
|
|
private array $updatedFiles = [];
|
|
|
|
/** Errors encountered during this run */
|
|
private array $errors = [];
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers');
|
|
$this->addArgument('--path', 'Repository root path', '.');
|
|
$this->addArgument('--dry-run', 'Preview changes without writing', false);
|
|
$this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false);
|
|
$this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', '');
|
|
}
|
|
|
|
protected function initialize(): void
|
|
{
|
|
parent::initialize();
|
|
$this->logger = new AuditLogger('update_version_from_readme');
|
|
}
|
|
|
|
protected function run(): int
|
|
{
|
|
$repoRoot = rtrim((string) $this->getArgument('--path'), '/');
|
|
$dryRun = (bool) $this->getArgument('--dry-run');
|
|
$createIssue = (bool) $this->getArgument('--create-issue');
|
|
$repo = (string) $this->getArgument('--repo');
|
|
|
|
$readmePath = $repoRoot . '/README.md';
|
|
if (!file_exists($readmePath)) {
|
|
$this->error("README.md not found at {$readmePath}");
|
|
return 1;
|
|
}
|
|
|
|
// ── 1. Extract version from README.md ────────────────────────────
|
|
$version = $this->extractVersionFromReadme($readmePath);
|
|
if ($version === null) {
|
|
$this->error("Could not find VERSION field in README.md FILE INFORMATION block");
|
|
return 1;
|
|
}
|
|
|
|
$this->log("✅ README.md version: {$version}");
|
|
if ($dryRun) {
|
|
$this->log("🔍 DRY RUN — no files will be written");
|
|
}
|
|
|
|
// ── 2. Scan and update every tracked file ────────────────────────
|
|
$this->processFiles($repoRoot, $version, $dryRun);
|
|
|
|
// ── 3. Update composer.json ──────────────────────────────────────
|
|
$this->updateComposerJson($repoRoot, $version, $dryRun);
|
|
|
|
// ── 4. Summary ───────────────────────────────────────────────────
|
|
$count = count($this->updatedFiles);
|
|
if ($dryRun) {
|
|
$this->log("🔍 DRY RUN complete — {$count} file(s) would be updated");
|
|
} else {
|
|
$this->log("✅ Updated {$count} file(s) to version {$version}");
|
|
}
|
|
|
|
foreach ($this->updatedFiles as $f) {
|
|
$this->log(" ✓ {$f}");
|
|
}
|
|
|
|
// ── 5. Create issue if mismatches remain (non-dry-run only) ──────
|
|
if (!$dryRun && $createIssue && !empty($repo)) {
|
|
$remaining = $this->countRemainingMismatches($repoRoot, $version);
|
|
if ($remaining > 0) {
|
|
$this->log("⚠ {$remaining} version reference(s) could not be auto-updated");
|
|
$this->createDriftIssue($repo, $version, $remaining);
|
|
}
|
|
}
|
|
|
|
return empty($this->errors) ? 0 : 1;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Version extraction
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract the VERSION value from the FILE INFORMATION block in README.md.
|
|
*
|
|
* Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms.
|
|
*
|
|
* @param string $path Full path to README.md
|
|
* @return string|null Version string (e.g. "04.00.04"), or null if not found
|
|
*/
|
|
private function extractVersionFromReadme(string $path): ?string
|
|
{
|
|
$content = file_get_contents($path);
|
|
if ($content === false) {
|
|
return null;
|
|
}
|
|
// Match "VERSION: XX.YY.ZZ" allowing leading whitespace/tab
|
|
if (preg_match('/^\s*VERSION:\s*([0-9]{2}\.[0-9]{2}\.[0-9]{2})\s*$/m', $content, $m)) {
|
|
return $m[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// File processing
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Walk the repository tree and update every eligible file.
|
|
*
|
|
* @param string $repoRoot Absolute path to repository root
|
|
* @param string $version Target version string
|
|
* @param bool $dryRun If true, compute but do not write changes
|
|
*/
|
|
private function processFiles(string $repoRoot, string $version, bool $dryRun): void
|
|
{
|
|
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf'];
|
|
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveCallbackFilterIterator(
|
|
new RecursiveDirectoryIterator(
|
|
$repoRoot,
|
|
RecursiveDirectoryIterator::SKIP_DOTS
|
|
),
|
|
function (\SplFileInfo $fi) use ($excludeDirs): bool {
|
|
if ($fi->isDir()) {
|
|
return !in_array($fi->getFilename(), $excludeDirs, true);
|
|
}
|
|
return true;
|
|
}
|
|
)
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
/** @var \SplFileInfo $file */
|
|
if (!$file->isFile()) {
|
|
continue;
|
|
}
|
|
|
|
$ext = strtolower($file->getExtension());
|
|
// Strip .template suffix for extension matching
|
|
if ($ext === 'template') {
|
|
$inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
|
|
if (in_array($inner, $extensions, true)) {
|
|
$ext = $inner;
|
|
} else {
|
|
continue;
|
|
}
|
|
} elseif (!in_array($ext, $extensions, true)) {
|
|
continue;
|
|
}
|
|
|
|
$this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply version replacements to a single file.
|
|
*
|
|
* @param string $path Absolute file path
|
|
* @param string $repoRoot Repository root (for display)
|
|
* @param string $version Target version
|
|
* @param bool $dryRun If true, do not write
|
|
* @param string $ext Canonical extension (without .template)
|
|
*/
|
|
private function processFile(
|
|
string $path,
|
|
string $repoRoot,
|
|
string $version,
|
|
bool $dryRun,
|
|
string $ext
|
|
): void {
|
|
$original = file_get_contents($path);
|
|
if ($original === false) {
|
|
$this->errors[] = "Cannot read: {$path}";
|
|
return;
|
|
}
|
|
|
|
$updated = $original;
|
|
|
|
// ── Badge replacement (all file types) ───────────────────────────
|
|
// shields.io badge: []
|
|
$updated = preg_replace(
|
|
'/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/',
|
|
'${1}' . $version . '${2}',
|
|
$updated
|
|
);
|
|
// Plain text version badge: [VERSION: XX.YY.ZZ]
|
|
$updated = preg_replace(
|
|
'/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/',
|
|
'[VERSION: ' . $version . ']',
|
|
$updated
|
|
);
|
|
|
|
// ── FILE INFORMATION VERSION replacement ──────────────────────────
|
|
// Markdown inside <!-- -->: VERSION: OLD or <tab>VERSION: OLD
|
|
if ($ext === 'md') {
|
|
$updated = preg_replace(
|
|
'/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
|
'${1}' . $version . '${2}',
|
|
$updated
|
|
);
|
|
}
|
|
|
|
// PHP inside /** */ or /* */: * VERSION: OLD
|
|
if ($ext === 'php') {
|
|
$updated = preg_replace(
|
|
'/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
|
'${1}' . $version . '${2}',
|
|
$updated
|
|
);
|
|
|
|
// PHP class VERSION constants:
|
|
// private const VERSION = '04.06.00';
|
|
// public const VERSION = '04.06.00';
|
|
// private const VERSION = '04.06.00';
|
|
$updated = preg_replace(
|
|
'/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/',
|
|
'${1}' . $version . '${2}',
|
|
$updated
|
|
);
|
|
|
|
// composer.json "version" field (handled separately for JSON files)
|
|
}
|
|
|
|
// YAML / Shell / PowerShell / Python: # VERSION: OLD
|
|
if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) {
|
|
$updated = preg_replace(
|
|
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
|
'${1}' . $version . '${2}',
|
|
$updated
|
|
);
|
|
}
|
|
|
|
// Terraform (.tf / .tf.template) — three locations:
|
|
// 1. # VERSION: OLD (hash-comment header, template-style files)
|
|
// 2. * Version: OLD (block-comment header, definition files)
|
|
// 3. version = "OLD" (HCL metadata field)
|
|
if ($ext === 'tf') {
|
|
$updated = preg_replace(
|
|
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
|
'${1}' . $version . '${2}',
|
|
$updated
|
|
);
|
|
$updated = preg_replace(
|
|
'/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
|
|
'${1}' . $version . '${2}',
|
|
$updated
|
|
);
|
|
$updated = preg_replace(
|
|
'/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m',
|
|
'${1}' . $version . '${2}',
|
|
$updated
|
|
);
|
|
}
|
|
|
|
if ($updated === $original) {
|
|
return; // Nothing to change
|
|
}
|
|
|
|
$rel = ltrim(str_replace($repoRoot, '', $path), '/');
|
|
|
|
if (!$dryRun) {
|
|
if (file_put_contents($path, $updated) === false) {
|
|
$this->errors[] = "Cannot write: {$path}";
|
|
return;
|
|
}
|
|
}
|
|
|
|
$this->updatedFiles[] = $rel;
|
|
}
|
|
|
|
/**
|
|
* Update the "version" key in composer.json if it exists.
|
|
*
|
|
* @param string $repoRoot Repository root
|
|
* @param string $version Target version
|
|
* @param bool $dryRun If true, do not write
|
|
*/
|
|
private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void
|
|
{
|
|
$path = $repoRoot . '/composer.json';
|
|
if (!file_exists($path)) {
|
|
return;
|
|
}
|
|
|
|
$content = file_get_contents($path);
|
|
if ($content === false) {
|
|
return;
|
|
}
|
|
|
|
$updated = preg_replace(
|
|
'/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m',
|
|
'${1}' . $version . '${2}',
|
|
$content
|
|
);
|
|
|
|
if ($updated === $content) {
|
|
return;
|
|
}
|
|
|
|
if (!$dryRun) {
|
|
file_put_contents($path, $updated);
|
|
}
|
|
|
|
$this->updatedFiles[] = 'composer.json';
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// Drift detection
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Count FILE INFORMATION VERSION lines that still differ from $version.
|
|
*
|
|
* @param string $repoRoot Repository root
|
|
* @param string $version Expected version
|
|
* @return int Number of remaining mismatches
|
|
*/
|
|
private function countRemainingMismatches(string $repoRoot, string $version): int
|
|
{
|
|
$escaped = preg_quote($version, '/');
|
|
$count = 0;
|
|
$versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/';
|
|
|
|
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf'];
|
|
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveCallbackFilterIterator(
|
|
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
|
|
function (\SplFileInfo $fi) use ($excludeDirs): bool {
|
|
return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true));
|
|
}
|
|
)
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
/** @var \SplFileInfo $file */
|
|
if (!$file->isFile()) {
|
|
continue;
|
|
}
|
|
$ext = strtolower($file->getExtension());
|
|
if ($ext === 'template') {
|
|
$ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
|
|
}
|
|
if (!in_array($ext, $extensions, true)) {
|
|
continue;
|
|
}
|
|
$content = file_get_contents($file->getPathname());
|
|
if ($content !== false && preg_match($versionRe, $content)) {
|
|
$count++;
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// GitHub issue creation
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create or update a GitHub issue listing files that could not be auto-updated.
|
|
*
|
|
* @param string $repo owner/repo
|
|
* @param string $version Expected version
|
|
* @param int $remaining Number of remaining mismatches
|
|
*/
|
|
private function createDriftIssue(string $repo, string $version, int $remaining): void
|
|
{
|
|
if (!isset($this->apiClient)) {
|
|
$config = \MokoEnterprise\Config::load();
|
|
try {
|
|
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
|
|
$this->apiClient = $adapter->getApiClient();
|
|
} catch (\Exception $e) {
|
|
$this->error('Platform initialization failed: ' . $e->getMessage());
|
|
return;
|
|
}
|
|
}
|
|
|
|
$title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}";
|
|
$labels = ['version-drift', 'maintenance', 'type: chore', 'automation'];
|
|
$body = implode("\n", [
|
|
"## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated",
|
|
"",
|
|
"**Target version:** `{$version}` (from README.md)",
|
|
"",
|
|
"After the automatic version propagation run, **{$remaining}** file(s) still contain",
|
|
"a VERSION field that does not match the README.md version.",
|
|
"",
|
|
"### How to fix",
|
|
"",
|
|
"1. Run the sync script locally:",
|
|
" ```bash",
|
|
" php maintenance/update_version_from_readme.php --path . --dry-run",
|
|
" php maintenance/update_version_from_readme.php --path .",
|
|
" ```",
|
|
"2. Inspect any files still flagged — they may use a non-standard VERSION format.",
|
|
"3. Update them manually to match `VERSION: {$version}`.",
|
|
"4. Commit and push — this issue will be closed automatically on the next successful sync.",
|
|
"",
|
|
"---",
|
|
"*Automatically created by [update_version_from_readme.php](maintenance/update_version_from_readme.php)*",
|
|
]);
|
|
|
|
try {
|
|
// Check for an existing version-drift issue to avoid duplicates
|
|
$existing = $this->apiClient->get("/repos/{$repo}/issues", [
|
|
'labels' => 'version-drift',
|
|
'state' => 'all',
|
|
'per_page' => 1,
|
|
'sort' => 'created',
|
|
'direction' => 'desc',
|
|
]);
|
|
|
|
if (!empty($existing[0]['number'])) {
|
|
$num = (int) $existing[0]['number'];
|
|
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
|
|
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
|
$patch['state'] = 'open';
|
|
}
|
|
$this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch);
|
|
try {
|
|
$this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
|
|
} catch (\Exception $le) { /* non-fatal */ }
|
|
$this->log("✅ Updated issue #{$num} in {$repo}");
|
|
} else {
|
|
$issue = $this->apiClient->post("/repos/{$repo}/issues", [
|
|
'title' => $title,
|
|
'body' => $body,
|
|
'labels' => $labels,
|
|
'assignees' => ['jmiller'],
|
|
]);
|
|
$this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}");
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->error('Failed to create/update issue: ' . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
$script = new UpdateVersionFromReadme();
|
|
exit($script->execute());
|