Files
moko-platform/maintenance/update_version_from_readme.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

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: [![MokoStandards](https://img.shields.io/badge/MokoStandards-OLD-blue)]
* - 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: [![MokoStandards](...badge/MokoStandards-XX.YY.ZZ-color)]
$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());