Files
Jonathan Miller 95880d3e44
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokocli CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
Platform: mokocli CI / Gate 1: Code Quality (pull_request) Failing after 53s
chore: complete namespace cleanup — remove all mokoplatform/MokoStandards/MokoEnterprise refs
390 files: templates, workflows, MCP servers, CLI tools, lib, deploy,
validate, wrappers, configs, docs. Pure find-and-replace.
2026-06-21 01:18:13 -05:00

237 lines
7.0 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: 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<string, string> resolved-ref -> SHA cache */
private array $shaCache = [];
/** @var list<array{file:string,line:int,old:string,new:string}> */
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());