Files
Jonathan Miller 1d87be7d5e
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
fix: standardize file headers — REPO rename, SPDX case, missing fields
- Update REPO: from MokoStandards-API to moko-platform in 125 files
- Fix wrong org path (mokoconsulting-tech → MokoConsulting) in 10 files
- Fix SPDX-LICENSE-IDENTIFIER case in 2 template files
- Add missing REPO: field to 3 files

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 17:01:17 -05:00

318 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: MokoStandards.Scripts.Maintenance
* INGROUP: MokoStandards
* 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';
use MokoEnterprise\Config;
use MokoEnterprise\GitPlatformAdapter;
use MokoEnterprise\PlatformAdapterFactory;
/**
* GitHub Actions SHA Pinner
*
* Scans all workflow YAML files under the platform workflow directory and
* replaces any tag-based or branch-based action reference with the corresponding
* pinned commit SHA. Already-pinned references (40-char hex SHA) are left untouched.
*
* Usage:
* php maintenance/pin_action_shas.php [--dry-run] [--verbose] [--help]
*
* Environment:
* GH_TOKEN Personal access token for GitHub API calls.
* GITEA_TOKEN Personal access token for Gitea API calls.
* GIT_PLATFORM 'github' (default) or 'gitea'
*/
class ActionShaPinner
{
private const TIMEOUT_SECS = 15;
private bool $dryRun = false;
private bool $verbose = false;
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 = [];
// -------------------------------------------------------------------------
// Bootstrap
// -------------------------------------------------------------------------
public function __construct(array $args)
{
$this->parseArguments($args);
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
$this->workflowsDir = $this->adapter->getWorkflowDir();
} catch (\RuntimeException $e) {
fwrite(STDERR, "Warning: " . $e->getMessage() . " — falling back to unauthenticated mode\n");
}
}
private function parseArguments(array $args): void
{
foreach ($args as $arg) {
if ($arg === '--dry-run') {
$this->dryRun = true;
} elseif ($arg === '--verbose' || $arg === '-v') {
$this->verbose = true;
} elseif ($arg === '--help' || $arg === '-h') {
$this->showHelp();
exit(0);
}
}
}
private function showHelp(): void
{
echo <<<'HELP'
Usage: php maintenance/pin_action_shas.php [OPTIONS]
Pins GitHub Actions to immutable commit SHAs in all .github/workflows/*.yml
files. Already-pinned references (40-character commit SHA) are skipped.
Options:
--dry-run Show changes that would be made without modifying any file
--verbose Show detailed per-action resolution output
--help Show this help message and exit
Environment:
GH_TOKEN GitHub API token (org secret, recommended to avoid rate limiting)
Falls back to GITHUB_TOKEN if GH_TOKEN is not set.
Examples:
# Preview all changes
GH_TOKEN=ghp_xxx php maintenance/pin_action_shas.php --dry-run --verbose
# Apply changes
GH_TOKEN=ghp_xxx php maintenance/pin_action_shas.php
HELP;
}
// -------------------------------------------------------------------------
// Main entry point
// -------------------------------------------------------------------------
public function run(): int
{
$this->log("🔒 GitHub Actions SHA Pinner", true);
$this->log(str_repeat('=', 50), true);
if ($this->dryRun) {
$this->log("Mode: DRY RUN (no files will be modified)\n", true);
}
// 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('No workflow files found in ' . self::WORKFLOWS_DIR, true);
return 0;
}
$this->log('Found ' . count($files) . " workflow file(s)\n", true);
foreach ($files as $file) {
$this->processFile($file);
}
$this->printSummary(count($files));
return 0;
}
// -------------------------------------------------------------------------
// File processing
// -------------------------------------------------------------------------
private function processFile(string $file): void
{
$content = file_get_contents($file);
if ($content === false) {
fwrite(STDERR, "❌ Cannot read: {$file}\n");
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) {
fwrite(STDERR, "❌ Cannot write: {$file}\n");
return;
}
}
$this->log(($this->dryRun ? '(dry-run) ' : '') . "✏️ Updated: {$file}", true);
} else {
$this->log("✓ 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
{
// Match: <indent>uses: <action>@<ref> [# optional comment]
// The ref must NOT already be a 40-character hex SHA.
if (!preg_match(
'/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/',
$line,
$m
)) {
return null;
}
[, $prefix, $action, $ref, $trailingComment] = $m;
// Already pinned nothing to do
if (preg_match('/^[0-9a-f]{40}$/', $ref)) {
$this->log(" ✓ Already pinned: {$action}@{$ref}");
return null;
}
// Derive owner/repo from the action path
// e.g. "github/codeql-action/init" → owner=github, repo=codeql-action
$segments = explode('/', $action);
if (count($segments) < 2) {
return null;
}
[$owner, $repo] = $segments;
$sha = $this->resolveTagToSha($owner, $repo, $ref);
if ($sha === null) {
fwrite(STDERR, "⚠️ Cannot resolve {$action}@{$ref} ({$file}:{$lineNum}) skipping\n");
return null;
}
// Preserve original trailing whitespace / newline handling; strip any
// existing comment so we replace it with the canonical one.
return "{$prefix}{$action}@{$sha} # {$ref}";
}
// -------------------------------------------------------------------------
// GitHub API helpers
// -------------------------------------------------------------------------
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];
}
$this->log(" Resolving {$owner}/{$repo}@{$ref} …");
$sha = null;
if ($this->adapter !== null) {
// Use the platform adapter for ref resolution
try {
$sha = $this->adapter->resolveRef($owner, $repo, $ref);
if (empty($sha)) {
$sha = null;
}
} catch (\Exception $e) {
$this->log(" Warning: adapter resolve failed: " . $e->getMessage());
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
if ($sha !== null) {
$this->log(" -> {$sha}");
}
$this->shaCache[$cacheKey] = $sha;
return $sha;
}
// -------------------------------------------------------------------------
// Output helpers
// -------------------------------------------------------------------------
private function printSummary(int $fileCount): void
{
$this->log("\nSummary:", true);
$this->log(" Files scanned: {$fileCount}", true);
$this->log(" Actions pinned: " . count($this->changes), true);
if (!empty($this->changes)) {
$this->log("\nChanges made:", true);
foreach ($this->changes as $change) {
$this->log(" {$change['file']}:{$change['line']}", true);
$this->log(" - {$change['old']}", true);
$this->log(" + {$change['new']}", true);
}
} else {
$this->log("\n✅ All actions are already pinned to commit SHAs", true);
}
}
private function log(string $message, bool $force = false): void
{
if ($this->verbose || $force) {
echo $message . "\n";
}
}
}
// Entry point
$pinner = new ActionShaPinner(array_slice($argv, 1));
exit($pinner->run());