1d87be7d5e
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
- 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>
318 lines
10 KiB
PHP
318 lines
10 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: 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());
|