diff --git a/CHANGELOG.md b/CHANGELOG.md index cddb930..7d795d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,17 @@ Version format: `XX.YY.ZZ` (zero-padded semver). ## [Unreleased] +### Added +- `cli/bulk_workflow_push.php` — push a workflow file to all governed repos via Gitea Contents API (closes #52) + +### Fixed +- auto-release workflow: switch trigger from `pull_request closed` to `push` on main (closes #54) +- CI Gate 1: add ondrej/php PPA + composer package for PHP 8.2 on runners +- CI repo-health: use `.mokogitea/workflows/` instead of `.gitea/workflows/` +- PHPCS: fix all 7,539 PSR-12 violations across 74 files +- PHPStan: fix deprecated config options, mark as advisory until errors addressed +- Branch protection: update check names from `MokoStandards CI` to `moko-platform CI` + ## [05.00.00] - 2026-05-16 ### Added diff --git a/cli/bulk_workflow_push.php b/cli/bulk_workflow_push.php new file mode 100644 index 0000000..cb76e78 --- /dev/null +++ b/cli/bulk_workflow_push.php @@ -0,0 +1,387 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/bulk_workflow_push.php + * VERSION: 01.00.00 + * BRIEF: Push a workflow file to all governed repos via the Gitea Contents API + */ + +declare(strict_types=1); + +final class BulkWorkflowPush +{ + private string $giteaUrl = 'https://git.mokoconsulting.tech'; + private string $token = ''; + private string $org = ''; + private string $workflowFile = ''; + private string $destPath = ''; + private string $branch = 'main'; + private bool $dryRun = false; + + private int $updated = 0; + private int $created = 0; + private int $skipped = 0; + private int $errors = 0; + + public function run(): int + { + $this->parseArgs(); + + if ($this->token === '') { + $this->log('ERROR: --token is required.'); + $this->printUsage(); + return 1; + } + + if ($this->workflowFile === '') { + $this->log('ERROR: --file is required.'); + $this->printUsage(); + return 1; + } + + if (!file_exists($this->workflowFile)) { + $this->log("ERROR: File not found: {$this->workflowFile}"); + return 1; + } + + if ($this->org === '') { + $this->log('ERROR: --org is required.'); + $this->printUsage(); + return 1; + } + + if ($this->destPath === '') { + $this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile); + } + + $localContent = file_get_contents($this->workflowFile); + + if ($localContent === false) { + $this->log("ERROR: Could not read file: {$this->workflowFile}"); + return 1; + } + + $this->log("Pushing: {$this->workflowFile}"); + $this->log(" -> {$this->destPath} (branch: {$this->branch})"); + $this->log(" -> Org: {$this->org} @ {$this->giteaUrl}"); + + if ($this->dryRun) { + $this->log('[DRY RUN] No changes will be made.'); + } + + $this->log(''); + + $repos = $this->fetchOrgRepos(); + + if ($repos === null) { + return 1; + } + + $this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\"."); + $this->log(''); + $this->log(sprintf('%-45s | %s', 'Repo', 'Status')); + $this->log(str_repeat('-', 70)); + + $encodedContent = base64_encode($localContent); + + foreach ($repos as $repo) { + $this->pushToRepo($repo, $encodedContent, $localContent); + } + + $this->log(''); + $this->log("Done: {$this->created} created, {$this->updated} updated, " + . "{$this->skipped} skipped, {$this->errors} error(s)."); + + return $this->errors > 0 ? 1 : 0; + } + + private function pushToRepo( + string $repoFullName, + string $encodedContent, + string $localContent + ): void { + [$owner, $repoName] = explode('/', $repoFullName, 2); + + $existing = $this->apiRequest( + 'GET', + "/api/v1/repos/{$owner}/{$repoName}/contents/" + . "{$this->destPath}?ref={$this->branch}" + ); + + if ($existing['code'] === 200) { + $data = json_decode($existing['body'], true); + $remoteSha = $data['sha'] ?? ''; + $remoteContent = base64_decode($data['content'] ?? ''); + + if ($remoteContent === $localContent) { + $this->log(sprintf( + '%-45s | %s', + $repoFullName, + 'IDENTICAL (skipped)' + )); + $this->skipped++; + return; + } + + if ($this->dryRun) { + $this->log(sprintf( + '%-45s | %s', + $repoFullName, + 'WOULD UPDATE' + )); + $this->updated++; + return; + } + + $payload = json_encode([ + 'content' => $encodedContent, + 'sha' => $remoteSha, + 'message' => "chore: sync {$this->destPath} " + . "from moko-platform [skip ci]", + 'branch' => $this->branch, + ]); + + $response = $this->apiRequest( + 'PUT', + "/api/v1/repos/{$owner}/{$repoName}/contents/" + . $this->destPath, + $payload + ); + + if ($response['code'] === 200) { + $this->log(sprintf( + '%-45s | %s', + $repoFullName, + 'UPDATED' + )); + $this->updated++; + } else { + $this->log(sprintf( + '%-45s | %s', + $repoFullName, + "ERROR (HTTP {$response['code']})" + )); + $this->errors++; + } + } elseif ($existing['code'] === 404) { + if ($this->dryRun) { + $this->log(sprintf( + '%-45s | %s', + $repoFullName, + 'WOULD CREATE' + )); + $this->created++; + return; + } + + $payload = json_encode([ + 'content' => $encodedContent, + 'message' => "chore: add {$this->destPath} " + . "from moko-platform [skip ci]", + 'branch' => $this->branch, + ]); + + $response = $this->apiRequest( + 'POST', + "/api/v1/repos/{$owner}/{$repoName}/contents/" + . $this->destPath, + $payload + ); + + if ($response['code'] === 201) { + $this->log(sprintf( + '%-45s | %s', + $repoFullName, + 'CREATED' + )); + $this->created++; + } else { + $this->log(sprintf( + '%-45s | %s', + $repoFullName, + "ERROR (HTTP {$response['code']})" + )); + $this->errors++; + } + } else { + $this->log(sprintf( + '%-45s | %s', + $repoFullName, + "ERROR (HTTP {$existing['code']})" + )); + $this->errors++; + } + } + + private function fetchOrgRepos(): ?array + { + $this->log("Fetching repos from org: {$this->org}"); + + $page = 1; + $repos = []; + + while (true) { + $response = $this->apiRequest( + 'GET', + "/api/v1/orgs/{$this->org}/repos?" + . "limit=50&page={$page}" + ); + + if ($response['code'] < 200 || $response['code'] >= 300) { + if ($page === 1) { + $this->log("ERROR: Could not fetch repos " + . "(HTTP {$response['code']})."); + return null; + } + + break; + } + + $data = json_decode($response['body'], true); + + if (!is_array($data) || count($data) === 0) { + break; + } + + foreach ($data as $repo) { + if (!empty($repo['archived'])) { + continue; + } + + $fullName = $repo['full_name'] ?? ''; + + if ($fullName !== '') { + $repos[] = $fullName; + } + } + + $page++; + } + + return $repos; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) { + switch ($args[$i]) { + case '--gitea-url': + $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); + break; + case '--token': + $this->token = $args[++$i] ?? ''; + break; + case '--org': + $this->org = $args[++$i] ?? ''; + break; + case '--file': + $this->workflowFile = $args[++$i] ?? ''; + break; + case '--dest': + $this->destPath = $args[++$i] ?? ''; + break; + case '--branch': + $this->branch = $args[++$i] ?? 'main'; + break; + case '--dry-run': + $this->dryRun = true; + break; + case '--help': + case '-h': + $this->printUsage(); + exit(0); + default: + $this->log("WARNING: Unknown argument: {$args[$i]}"); + break; + } + } + } + + private function printUsage(): void + { + $this->log( + 'Usage: bulk_workflow_push.php ' + . '--token --file --org [options]' + ); + $this->log(''); + $this->log( + 'Push a workflow file from moko-platform ' + . 'to all governed repos.' + ); + $this->log(''); + $this->log('Options:'); + $this->log(' --gitea-url Gitea URL ' + . '(default: https://git.mokoconsulting.tech)'); + $this->log(' --token Gitea API token'); + $this->log(' --org Target organization'); + $this->log(' --file Local workflow file to push'); + $this->log(' --dest Destination path in repos ' + . '(default: .mokogitea/workflows/)'); + $this->log(' --branch Target branch (default: main)'); + $this->log(' --dry-run Show what would be done'); + $this->log(' --help, -h Show this help'); + } + + private function apiRequest( + string $method, + string $endpoint, + ?string $body = null + ): array { + $url = $this->giteaUrl . $endpoint; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Accept: application/json', + "Authorization: token {$this->token}", + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo( + $ch, + CURLINFO_HTTP_CODE + ); + + if (curl_errno($ch)) { + $error = curl_error($ch); + curl_close($ch); + + return [ + 'code' => 0, + 'body' => "cURL error: {$error}", + ]; + } + + curl_close($ch); + + return ['code' => $httpCode, 'body' => $responseBody]; + } + + private function log(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} + +$app = new BulkWorkflowPush(); +exit($app->run());