#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoPlatform.Scripts.Maintenance * INGROUP: MokoPlatform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * PATH: /maintenance/pin_action_shas.php * BRIEF: Pin GitHub Actions to immutable commit SHAs in workflow files * NOTE: Resolves tag/branch refs to commit SHAs via the GitHub API to satisfy * the CodeQL "Unpinned tag for a non-immutable Action" security rule. */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoCli\CliFramework; use MokoCli\Config; use MokoCli\GitPlatformAdapter; use MokoCli\PlatformAdapterFactory; class PinActionShasCli extends CliFramework { private ?GitPlatformAdapter $adapter = null; private string $workflowsDir = '.github/workflows'; /** @var array resolved-ref -> SHA cache */ private array $shaCache = []; /** @var list */ private array $changes = []; protected function configure(): void { $this->setDescription('Pin GitHub Actions to immutable commit SHAs in workflow files'); } protected function initialize(): void { $config = Config::load(); try { $this->adapter = PlatformAdapterFactory::create($config); $this->workflowsDir = $this->adapter->getWorkflowDir(); } catch (\RuntimeException $e) { $this->log('WARNING', $e->getMessage() . " — falling back to unauthenticated mode"); } } protected function run(): int { $this->log('INFO', "GitHub Actions SHA Pinner"); $this->log('INFO', str_repeat('=', 50)); if ($this->dryRun) { $this->log('INFO', "Mode: DRY RUN (no files will be modified)\n"); } // Also check .github/workflows if on Gitea (workflows may exist in both dirs) $dirs = [$this->workflowsDir]; if ($this->workflowsDir !== '.github/workflows' && is_dir('.github/workflows')) { $dirs[] = '.github/workflows'; } $files = []; foreach ($dirs as $dir) { $files = array_merge($files, glob($dir . '/*.yml') ?: []); } if (empty($files)) { $this->log('INFO', 'No workflow files found in ' . $this->workflowsDir); return 0; } $this->log('INFO', 'Found ' . count($files) . " workflow file(s)\n"); foreach ($files as $file) { $this->processFile($file); } $this->printChangeSummary(count($files)); return 0; } private function processFile(string $file): void { $content = file_get_contents($file); if ($content === false) { $this->log('ERROR', "Cannot read: {$file}"); return; } $lines = explode("\n", $content); $modified = false; foreach ($lines as $idx => &$line) { $updated = $this->processLine($line, $file, $idx + 1); if ($updated !== null) { $this->changes[] = [ 'file' => $file, 'line' => $idx + 1, 'old' => trim($line), 'new' => trim($updated), ]; $line = $updated; $modified = true; } } unset($line); if ($modified) { if (!$this->dryRun) { if (file_put_contents($file, implode("\n", $lines)) === false) { $this->log('ERROR', "Cannot write: {$file}"); return; } } $this->log('INFO', ($this->dryRun ? '(dry-run) ' : '') . "Updated: {$file}"); } elseif ($this->verbose) { $this->log('INFO', "No changes: {$file}"); } } /** * Inspect one line and return the pinned replacement, or null if the line * does not need to be changed. */ private function processLine(string $line, string $file, int $lineNum): ?string { if ( !preg_match( '/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/', $line, $m ) ) { return null; } [, $prefix, $action, $ref, $trailingComment] = $m; // Already pinned if (preg_match('/^[0-9a-f]{40}$/', $ref)) { if ($this->verbose) { $this->log('INFO', " Already pinned: {$action}@{$ref}"); } return null; } $segments = explode('/', $action); if (count($segments) < 2) { return null; } [$owner, $repo] = $segments; $sha = $this->resolveTagToSha($owner, $repo, $ref); if ($sha === null) { $this->log('WARNING', "Cannot resolve {$action}@{$ref} ({$file}:{$lineNum}) — skipping"); return null; } return "{$prefix}{$action}@{$sha} # {$ref}"; } private function resolveTagToSha(string $owner, string $repo, string $ref): ?string { $cacheKey = "{$owner}/{$repo}@{$ref}"; if (array_key_exists($cacheKey, $this->shaCache)) { return $this->shaCache[$cacheKey]; } if ($this->verbose) { $this->log('INFO', " Resolving {$owner}/{$repo}@{$ref} ..."); } $sha = null; if ($this->adapter !== null) { try { $sha = $this->adapter->resolveRef($owner, $repo, $ref); if (empty($sha)) { $sha = null; } } catch (\Exception $e) { if ($this->verbose) { $this->log('WARNING', " adapter resolve failed: " . $e->getMessage()); } $this->adapter->getApiClient()->resetCircuitBreaker(); } } if ($sha !== null && $this->verbose) { $this->log('INFO', " -> {$sha}"); } $this->shaCache[$cacheKey] = $sha; return $sha; } private function printChangeSummary(int $fileCount): void { $this->log('INFO', "\nSummary:"); $this->log('INFO', " Files scanned: {$fileCount}"); $this->log('INFO', " Actions pinned: " . count($this->changes)); if (!empty($this->changes)) { $this->log('INFO', "\nChanges made:"); foreach ($this->changes as $change) { $this->log('INFO', " {$change['file']}:{$change['line']}"); $this->log('INFO', " - {$change['old']}"); $this->log('INFO', " + {$change['new']}"); } } else { $this->log('INFO', "\nAll actions are already pinned to commit SHAs"); } } } $app = new PinActionShasCli(); exit($app->execute());