66e728b078
Generic: Repo Health / Access control (push) Successful in 18s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Failing after 27s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 28s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m7s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Auto-fixed 5006 tab-indent and line-ending errors via phpcbf, then manually broke 100 lines exceeding 150-char limit. All 74 files in cli/, automation/, maintenance/, deploy/ now pass PHPCS PSR-12 clean. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
237 lines
7.0 KiB
PHP
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/moko-platform
|
|
* 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 MokoEnterprise\CliFramework;
|
|
use MokoEnterprise\Config;
|
|
use MokoEnterprise\GitPlatformAdapter;
|
|
use MokoEnterprise\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());
|