#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Scripts.Validate * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API * PATH: /validate/check_version_consistency.php * VERSION: 04.06.00 * BRIEF: Validates that version numbers are consistent across all critical repository files */ declare(strict_types=1); require_once __DIR__ . '/../../vendor/autoload.php'; use MokoEnterprise\CliFramework; /** * Checks that the version recorded in composer.json matches VERSION headers * and badges in README.md, CHANGELOG.md, CONTRIBUTING.md, workflow files, * and PHP Enterprise library files. */ class CheckVersionConsistency extends CliFramework { protected function configure(): void { $this->setDescription('Validates version consistency across all critical repository files'); $this->addArgument('--path', 'Repository root path to check', '.'); } protected function run(): int { $path = rtrim((string) $this->getArgument('--path'), '/\\'); $composerFile = $path . '/composer.json'; // ── Resolve expected version ────────────────────────────────────────── $this->section('Resolving expected version'); $expected = null; if (is_file($composerFile)) { $data = json_decode((string) file_get_contents($composerFile), true); if (isset($data['version'])) { $expected = (string) $data['version']; $this->status(true, "Expected version (composer.json): {$expected}"); } else { $this->status(false, 'composer.json', 'missing "version" key'); } } else { $this->status(false, 'composer.json', 'file not found — falling back to README.md'); } // Fallback: extract version from README.md VERSION header if ($expected === null) { $readmeFile = $path . '/README.md'; if (is_file($readmeFile)) { $readme = (string) file_get_contents($readmeFile); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', $readme, $m)) { $expected = $m[1]; $this->status(true, "Expected version (README.md): {$expected}"); } else { $this->status(false, 'README.md', 'no VERSION header found'); return 2; } } else { $this->status(false, 'README.md', 'file not found'); return 2; } } // ── Check critical root files ───────────────────────────────────────── $this->section('Checking critical files'); $criticalChecks = [ 'README.md' => ['/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', '/MokoStandards-(\d{2}\.\d{2}\.\d{2})/'], 'CHANGELOG.md' => ['/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/'], 'CONTRIBUTING.md' => ['/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/'], ]; $issues = []; foreach ($criticalChecks as $filename => $patterns) { $file = $path . '/' . $filename; if (!is_file($file)) { $this->status(false, $filename, 'file not found'); $issues[] = $filename; continue; } $content = (string) file_get_contents($file); $filePassed = true; foreach ($patterns as $pattern) { preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE); foreach ($matches[1] as $match) { if ($match[0] !== $expected) { $line = substr_count(substr($content, 0, (int) $match[1]), "\n") + 1; $this->status(false, $filename, "line {$line}: found {$match[0]}, expected {$expected}"); $issues[] = $filename; $filePassed = false; } } } if ($filePassed) { $this->status(true, $filename); } } // ── Check workflow files ────────────────────────────────────────────── $this->section('Checking workflow files'); // Check both .github/workflows and .gitea/workflows $workflowFiles = []; foreach (['.github/workflows', '.gitea/workflows'] as $wfDir) { $dir = $path . '/' . $wfDir; if (is_dir($dir)) { $workflowFiles = array_merge($workflowFiles, glob($dir . '/*.yml') ?: []); } } $total = count($workflowFiles); foreach ($workflowFiles as $i => $file) { $this->progress($i + 1, $total, basename($file)); $content = (string) file_get_contents($file); $filePassed = true; preg_match_all('/#\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', $content, $matches, PREG_OFFSET_CAPTURE); foreach ($matches[1] as $match) { if ($match[0] !== $expected) { $this->progress($i + 1, $total, '', true); $rel = str_replace($path . '/', '', $file); $this->status(false, $rel, "found {$match[0]}, expected {$expected}"); $issues[] = $rel; $filePassed = false; } } } $this->progress($total, $total, '', true); // ── Check PHP Enterprise library files ──────────────────────────────── $this->section('Checking PHP source files'); $phpFiles = $this->findPhpFiles($path . '/lib/Enterprise'); $phpTotal = count($phpFiles); foreach ($phpFiles as $i => $file) { $this->progress($i + 1, $phpTotal, basename($file)); $content = (string) file_get_contents($file); $filePassed = true; preg_match_all('/\* VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', $content, $matches, PREG_OFFSET_CAPTURE); foreach ($matches[1] as $match) { if ($match[0] !== $expected) { $this->progress($i + 1, $phpTotal, '', true); $rel = str_replace($path . '/', '', $file); $this->status(false, $rel, "found {$match[0]}, expected {$expected}"); $issues[] = $rel; $filePassed = false; } } } $this->progress($phpTotal, $phpTotal, '', true); // ── Check Terraform definition files ───────────────────────────────── // Each .tf file has TWO version locations that must both match: // - Block-comment header: * Version: XX.XX.XX // - HCL metadata field: version = "XX.XX.XX" $this->section('Checking Terraform definition files'); $defFiles = glob($path . '/definitions/default/*.tf') ?: []; $defTotal = count($defFiles); foreach ($defFiles as $i => $file) { $this->progress($i + 1, $defTotal, basename($file)); $content = (string) file_get_contents($file); $filePassed = true; $rel = str_replace($path . '/', '', $file); // Block-comment header version preg_match_all('/\*\s*Version:\s*(\d{2}\.\d{2}\.\d{2})/', $content, $headerMatches, PREG_OFFSET_CAPTURE); foreach ($headerMatches[1] as $match) { if ($match[0] !== $expected) { $this->progress($i + 1, $defTotal, '', true); $this->status(false, $rel, "header Version: found {$match[0]}, expected {$expected}"); $issues[] = $rel; $filePassed = false; } } // HCL metadata version field preg_match_all('/^\s*version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $hclMatches, PREG_OFFSET_CAPTURE); foreach ($hclMatches[1] as $match) { if ($match[0] !== $expected) { $this->progress($i + 1, $defTotal, '', true); $this->status(false, $rel, "HCL version = found {$match[0]}, expected {$expected}"); $issues[] = $rel; $filePassed = false; } } if ($filePassed) { $this->status(true, $rel); } } $this->progress($defTotal, $defTotal, '', true); // ── Summary ─────────────────────────────────────────────────────────── $totalChecked = count($criticalChecks) + $total + $phpTotal + $defTotal; $totalFailed = count(array_unique($issues)); $this->printSummary($totalChecked - $totalFailed, $totalFailed, $this->elapsed()); return $totalFailed === 0 ? 0 : 1; } /** * Recursively find all PHP files under a directory. * * @return list */ private function findPhpFiles(string $dir): array { if (!is_dir($dir)) { return []; } $files = []; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if ($file->isFile() && $file->getExtension() === 'php') { $files[] = $file->getPathname(); } } return $files; } } $script = new CheckVersionConsistency('check_version_consistency', 'Validates version consistency across repository files'); exit($script->execute());