|
| 1 | +#!/usr/bin/env pwsh |
| 2 | + |
| 3 | +<# |
| 4 | +.SYNOPSIS |
| 5 | + Finds GitHub Actions pinned to tags and optionally pins them to SHAs. |
| 6 | +.DESCRIPTION |
| 7 | + Scans .github/ for uses: references pinned to tags/branches. |
| 8 | + With -Fix, resolves each to a commit SHA via gh CLI and rewrites in-place, |
| 9 | + adding a comment on the line above: # SHA corresponds to action@tag |
| 10 | +#> |
| 11 | + |
| 12 | +[CmdletBinding()] |
| 13 | +param( |
| 14 | + [string]$Path = ".github", |
| 15 | + [switch]$Fix, |
| 16 | + # Restrict to a specific owner category: GitHub, Azure, Microsoft, 3P, Local |
| 17 | + [ValidateSet("GitHub", "Azure", "Microsoft", "3P", "Local")] |
| 18 | + [string]$OwnerType |
| 19 | +) |
| 20 | + |
| 21 | +Set-StrictMode -Version Latest |
| 22 | +$ErrorActionPreference = "Stop" |
| 23 | + |
| 24 | +$usesPattern = '(?<pre>\buses:\s*)(?<action>[a-zA-Z0-9_\-\.]+/[a-zA-Z0-9_\-\.]+)@(?<ref>[a-zA-Z0-9_\-\.\/]+)' |
| 25 | + |
| 26 | +function Get-Category([string]$Action) { |
| 27 | + $org = ($Action -split '/')[0].ToLower() |
| 28 | + if ($org -eq 'actions') { return 'GitHub owned' } |
| 29 | + if ($org -eq 'azure') { return 'Azure/ repo owned' } |
| 30 | + if ($org -eq 'microsoft') { return 'Microsoft/ repo owned' } |
| 31 | + return 'third party' |
| 32 | +} |
| 33 | + |
| 34 | +function Resolve-ActionSha([string]$Action, [string]$Ref) { |
| 35 | + $org, $repo = $Action -split '/', 2 |
| 36 | + |
| 37 | + # Try tag first, then branch |
| 38 | + foreach ($refType in @("tags", "heads")) { |
| 39 | + try { |
| 40 | + $result = gh api "repos/$org/$repo/git/ref/$refType/$Ref" --jq '.object' 2>$null | ConvertFrom-Json |
| 41 | + } catch { continue } |
| 42 | + if (-not $result -or -not $result.PSObject.Properties['sha']) { continue } |
| 43 | + |
| 44 | + if ($result.PSObject.Properties['type'] -and $result.type -eq 'tag') { |
| 45 | + return (gh api "repos/$org/$repo/git/tags/$($result.sha)" --jq '.object.sha' 2>$null).Trim() |
| 46 | + } |
| 47 | + return $result.sha |
| 48 | + } |
| 49 | + return $null |
| 50 | +} |
| 51 | + |
| 52 | +# Find YAML files |
| 53 | +if (-not (Test-Path $Path)) { Write-Error "Path not found: $Path"; exit 1 } |
| 54 | +$yamlFiles = Get-ChildItem -Path $Path -Recurse -Include "*.yml", "*.yaml" -File |
| 55 | +if (-not $yamlFiles) { Write-Host "No YAML files found in $Path"; exit 0 } |
| 56 | + |
| 57 | +# Parse all uses: references |
| 58 | +$refs = foreach ($file in $yamlFiles) { |
| 59 | + $lines = Get-Content $file.FullName |
| 60 | + $relPath = Resolve-Path $file.FullName -Relative -ErrorAction SilentlyContinue |
| 61 | + if (-not $relPath) { $relPath = $file.FullName } |
| 62 | + |
| 63 | + for ($i = 0; $i -lt $lines.Count; $i++) { |
| 64 | + $line = $lines[$i] |
| 65 | + if ($line.TrimStart().StartsWith('#')) { continue } |
| 66 | + if ($line -match '\buses:\s*\./') { |
| 67 | + [PSCustomObject]@{ File = $relPath; Line = $i + 1; Action = '(local)'; Ref = ($line -replace '.*uses:\s*','').Trim(); Category = 'local'; IsSha = $false } |
| 68 | + continue |
| 69 | + } |
| 70 | + if ($line -match $usesPattern) { |
| 71 | + $ref = $Matches['ref'] |
| 72 | + [PSCustomObject]@{ File = $relPath; Line = $i + 1; Action = $Matches['action']; Ref = $ref; Category = Get-Category $Matches['action']; IsSha = ($ref -match '^[0-9a-f]{40}$') } |
| 73 | + } |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +# Map OwnerType to category name for filtering |
| 78 | +$ownerCategoryMap = @{ |
| 79 | + 'GitHub' = 'GitHub owned' |
| 80 | + 'Azure' = 'Azure/ repo owned' |
| 81 | + 'Microsoft' = 'Microsoft/ repo owned' |
| 82 | + '3P' = 'third party' |
| 83 | + 'Local' = 'local' |
| 84 | +} |
| 85 | + |
| 86 | +if ($OwnerType) { |
| 87 | + $refs = @($refs | Where-Object Category -eq $ownerCategoryMap[$OwnerType]) |
| 88 | +} |
| 89 | + |
| 90 | +if (-not $refs) { Write-Host "No action references found."; exit 0 } |
| 91 | + |
| 92 | +# Display grouped by category |
| 93 | +foreach ($cat in @('GitHub owned', 'Azure/ repo owned', 'Microsoft/ repo owned', 'third party', 'local')) { |
| 94 | + $group = @($refs | Where-Object Category -eq $cat) |
| 95 | + if ($group.Count -eq 0) { continue } |
| 96 | + |
| 97 | + Write-Host "`n=== $cat ===" -ForegroundColor Cyan |
| 98 | + foreach ($r in $group) { |
| 99 | + $pin = if ($r.IsSha) { "pinned" } elseif ($r.Category -eq 'local') { "local" } else { "TAG" } |
| 100 | + if ($pin -eq "TAG") { |
| 101 | + Write-Host " [$pin] $($r.Action)@$($r.Ref) ($($r.File):$($r.Line))" -ForegroundColor Red |
| 102 | + } else { |
| 103 | + Write-Host " [$pin] $($r.Action)@$($r.Ref) ($($r.File):$($r.Line))" |
| 104 | + } |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +$unpinned = @($refs | Where-Object { -not $_.IsSha -and $_.Category -ne 'local' }) |
| 109 | +Write-Host "`n$($unpinned.Count) unpinned reference(s) found." |
| 110 | + |
| 111 | +if (-not $Fix) { |
| 112 | + if ($unpinned.Count -gt 0) { Write-Host "Run with -Fix to pin them to SHAs." } |
| 113 | + exit 0 |
| 114 | +} |
| 115 | + |
| 116 | +# --- Fix mode --- |
| 117 | +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { |
| 118 | + Write-Error "gh CLI is required for -Fix mode. Install from https://cli.github.com/" |
| 119 | + exit 1 |
| 120 | +} |
| 121 | + |
| 122 | +# Resolve unique action@ref pairs |
| 123 | +$shaCache = @{} |
| 124 | +$unpinned | Select-Object Action, Ref -Unique | ForEach-Object { |
| 125 | + $key = "$($_.Action)@$($_.Ref)" |
| 126 | + Write-Host " Resolving $key ... " -NoNewline |
| 127 | + $sha = Resolve-ActionSha $_.Action $_.Ref |
| 128 | + if ($sha) { |
| 129 | + $shaCache[$key] = $sha |
| 130 | + Write-Host $sha -ForegroundColor Green |
| 131 | + } else { |
| 132 | + Write-Host "FAILED" -ForegroundColor Red |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +# Rewrite files |
| 137 | +$fixed = 0 |
| 138 | +foreach ($group in ($unpinned | Group-Object File)) { |
| 139 | + $fullPath = (Resolve-Path $group.Group[0].File).Path |
| 140 | + $lines = [System.Collections.Generic.List[string]](Get-Content $fullPath) |
| 141 | + $changed = $false |
| 142 | + |
| 143 | + # Process in reverse line order so insertions don't shift later indices |
| 144 | + foreach ($r in ($group.Group | Sort-Object Line -Descending)) { |
| 145 | + $sha = $shaCache["$($r.Action)@$($r.Ref)"] |
| 146 | + if (-not $sha) { continue } |
| 147 | + $idx = $r.Line - 1 |
| 148 | + $oldLine = $lines[$idx] |
| 149 | + $newLine = $oldLine -replace "(?<pre>uses:\s*$([regex]::Escape($r.Action)))@$([regex]::Escape($r.Ref))", "`${pre}@$sha" |
| 150 | + if ($newLine -ne $oldLine) { |
| 151 | + $lines[$idx] = $newLine |
| 152 | + $comment = "# SHA corresponds to $($r.Action)@$($r.Ref)" |
| 153 | + $indent = if ($oldLine -match '^(\s*)') { $Matches[1] } else { '' } |
| 154 | + # Update existing comment above if present, otherwise insert |
| 155 | + if ($idx -gt 0 -and $lines[$idx - 1] -match '^\s*# SHA corresponds to') { |
| 156 | + $lines[$idx - 1] = "$indent$comment" |
| 157 | + } else { |
| 158 | + $lines.Insert($idx, "$indent$comment") |
| 159 | + } |
| 160 | + $changed = $true |
| 161 | + $fixed++ |
| 162 | + } |
| 163 | + } |
| 164 | + if ($changed) { Set-Content $fullPath $lines } |
| 165 | +} |
| 166 | + |
| 167 | +Write-Host "`nFixed $fixed reference(s)." |
0 commit comments