Skip to content

Commit e1d4d67

Browse files
authored
Add script to analyze/update github actions tag references in a repository (Azure#14662)
* Add script to evaluate and fix github actions referencing tags instead of shas * Add filter mode for gh action owner type * Fix duplicate comment issue
1 parent dcbf45c commit e1d4d67

File tree

1 file changed

+167
-0
lines changed

1 file changed

+167
-0
lines changed

eng/scripts/Pin-GitHubActions.ps1

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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

Comments
 (0)