From 44b6db6eca1c1fca4673ca12c56806d45ca4acf5 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 12:42:11 +0200 Subject: [PATCH 1/4] Add GitHub Action SHA-pin updater Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Update-GitHubActionPin.ps1 | 265 +++++++++++++++++++++ tests/Update-GitHubActionPin.Tests.ps1 | 119 +++++++++ 2 files changed, 384 insertions(+) create mode 100644 .github/scripts/Update-GitHubActionPin.ps1 create mode 100644 tests/Update-GitHubActionPin.Tests.ps1 diff --git a/.github/scripts/Update-GitHubActionPin.ps1 b/.github/scripts/Update-GitHubActionPin.ps1 new file mode 100644 index 0000000..9becf0b --- /dev/null +++ b/.github/scripts/Update-GitHubActionPin.ps1 @@ -0,0 +1,265 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 + +<# +.SYNOPSIS + Synchronize SHA-pinned GitHub Actions with their latest stable releases. + +.DESCRIPTION + Recursively scans a target repository's .github directory for YAML files and updates + action references in the form 'uses: owner/repository[/subpath]@<40-character SHA>'. + Each reference is resolved to the repository's latest stable GitHub release, then to + the immutable commit SHA behind that release tag. Lightweight and annotated tags are + both supported. + + The script changes only external SHA-pinned action references. Local actions + ('./path') and Docker actions ('docker://image') do not match the supported reference + shape and are left unchanged. A trailing comment is treated as the release tag and is + replaced; the latest tag comment is added when it is absent. + + GitHub API requests use GITHUB_TOKEN or GH_TOKEN when either is present. The token is + sent only in the Authorization request header and is never written to output. No + external PowerShell modules are required. + +.EXAMPLE + ./Update-GitHubActionPin.ps1 -RepositoryPath ../service -WhatIf + + Shows the changes needed to synchronize SHA-pinned actions in ../service without + changing its YAML files. + +.EXAMPLE + ./Update-GitHubActionPin.ps1 -RepositoryPath ../service -Verbose + + Updates each SHA pin in ../service to the latest stable release and reports the + release lookups and changed files. + +.INPUTS + None. This script does not accept pipeline input. + +.OUTPUTS + [pscustomobject] with Path, Updates, and Updated properties for every YAML file that + contains a supported action reference. +#> +[OutputType([pscustomobject])] +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] +param( + # Repository whose .github YAML files are scanned. Defaults to the current directory. + [Parameter()] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] + [string] $RepositoryPath = (Get-Location).Path, + + # GitHub REST API root. GITHUB_API_URL allows the same script to run on GitHub Enterprise. + [Parameter()] + [uri] $ApiBaseUri = $(if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { 'https://api.github.com' }) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-GitHubRequestHeader { + <# + .SYNOPSIS + Build headers for an authenticated GitHub REST API request when a token is available. + #> + [OutputType([hashtable])] + [CmdletBinding()] + param() + + $headers = @{ + Accept = 'application/vnd.github+json' + 'User-Agent' = 'MSXOrg-GitHubActionPinUpdater' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $token = if ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } else { $env:GH_TOKEN } + if ($token) { + $null = $headers.Authorization = "Bearer $token" + } + + return $headers +} + +function Invoke-GitHubApiRequest { + <# + .SYNOPSIS + Request one GitHub REST API resource. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [uri] $Uri, + + [Parameter(Mandatory)] + [hashtable] $Headers + ) + + Write-Verbose "Querying GitHub API: $Uri" + return Invoke-RestMethod -Method Get -Uri $Uri -Headers $Headers +} + +function Get-GitHubReleaseCommit { + <# + .SYNOPSIS + Resolve an action repository's latest stable release tag to a commit SHA. + #> + [OutputType([pscustomobject])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[^/]+/[^/]+$')] + [string] $Repository, + + [Parameter(Mandatory)] + [uri] $ApiBaseUri, + + [Parameter(Mandatory)] + [hashtable] $Headers + ) + + $apiBase = $ApiBaseUri.AbsoluteUri.TrimEnd('/') + $releaseUri = "$apiBase/repos/$Repository/releases/latest" + $release = Invoke-GitHubApiRequest -Uri $releaseUri -Headers $Headers + $tag = [string] $release.tag_name + if ([string]::IsNullOrWhiteSpace($tag)) { + throw "GitHub returned no latest stable release tag for action repository '$Repository'." + } + + $escapedTag = [uri]::EscapeDataString($tag) + $referenceUri = "$apiBase/repos/$Repository/git/ref/tags/$escapedTag" + $reference = Invoke-GitHubApiRequest -Uri $referenceUri -Headers $Headers + $target = $reference.object + $seenTags = @{} + + while ($target.type -eq 'tag') { + $tagSha = [string] $target.sha + if ($tagSha -notmatch '^[0-9a-fA-F]{40}$') { + throw "GitHub returned an invalid annotated-tag SHA for '$Repository@$tag'." + } + if ($seenTags.ContainsKey($tagSha)) { + throw "GitHub returned a circular annotated-tag chain for '$Repository@$tag'." + } + $null = $seenTags[$tagSha] = $true + + $tagUri = "$apiBase/repos/$Repository/git/tags/$tagSha" + $annotatedTag = Invoke-GitHubApiRequest -Uri $tagUri -Headers $Headers + $target = $annotatedTag.object + } + + if ($target.type -ne 'commit' -or ([string] $target.sha) -notmatch '^[0-9a-fA-F]{40}$') { + throw "GitHub could not resolve latest stable release '$Repository@$tag' to a commit SHA." + } + + return [pscustomobject]@{ + Tag = $tag + Sha = ([string] $target.sha).ToLowerInvariant() + } +} + +function Get-GitHubActionReplacement { + <# + .SYNOPSIS + Build an updated uses: line from one supported action reference match. + #> + [OutputType([string])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Text.RegularExpressions.Match] $Match, + + [Parameter(Mandatory)] + [hashtable] $ReleaseCache, + + [Parameter(Mandatory)] + [uri] $ApiBaseUri, + + [Parameter(Mandatory)] + [hashtable] $Headers + ) + + $repository = "$($Match.Groups['owner'].Value)/$($Match.Groups['repository'].Value)" + $cacheKey = $repository.ToLowerInvariant() + if (-not $ReleaseCache.ContainsKey($cacheKey)) { + $null = $ReleaseCache[$cacheKey] = Get-GitHubReleaseCommit -Repository $repository -ApiBaseUri $ApiBaseUri -Headers $Headers + } + + $release = $ReleaseCache[$cacheKey] + $action = $Match.Groups['action'].Value + return "$($Match.Groups['prefix'].Value)$action@$($release.Sha) # $($release.Tag)" +} + +function Update-GitHubActionPin { + <# + .SYNOPSIS + Update SHA-pinned actions beneath a repository's .github directory. + #> + [OutputType([pscustomobject])] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] + [string] $RepositoryPath, + + [Parameter(Mandatory)] + [uri] $ApiBaseUri + ) + + $githubDirectory = Join-Path (Resolve-Path -LiteralPath $RepositoryPath).ProviderPath '.github' + if (-not (Test-Path -LiteralPath $githubDirectory -PathType Container)) { + throw "Target repository '$RepositoryPath' has no .github directory to scan." + } + + $headers = Get-GitHubRequestHeader + $releaseCache = @{} + $referencePattern = [regex]::new( + '(?m)(?^[ \t]*(?:-[ \t]*)?uses:[ \t]*)(?(?[\w.-]+)/(?[\w.-]+)(?:/[^\s@#]+)*)@[0-9a-fA-F]{40}(?:[ \t]*(?:#[^\r\n]*)?)(?=\r?$)' + ) + $yamlFiles = @(Get-ChildItem -LiteralPath $githubDirectory -Recurse -File | + Where-Object { $_.Extension -in @('.yml', '.yaml') } | + Sort-Object -Property FullName) + + if ($yamlFiles.Count -eq 0) { + Write-Verbose "No YAML files found beneath $githubDirectory." + return + } + + foreach ($file in $yamlFiles) { + $bytes = [System.IO.File]::ReadAllBytes($file.FullName) + $hasUtf8Bom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + $offset = if ($hasUtf8Bom) { 3 } else { 0 } + $content = [System.Text.Encoding]::UTF8.GetString($bytes, $offset, $bytes.Length - $offset) + $referenceMatches = $referencePattern.Matches($content) + if ($referenceMatches.Count -eq 0) { + continue + } + + $updatedContent = [System.Text.StringBuilder]::new() + $position = 0 + $updates = 0 + foreach ($match in $referenceMatches) { + $replacement = Get-GitHubActionReplacement -Match $match -ReleaseCache $releaseCache -ApiBaseUri $ApiBaseUri -Headers $headers + $null = $updatedContent.Append($content, $position, $match.Index - $position) + $null = $updatedContent.Append($replacement) + $position = $match.Index + $match.Length + $updates++ + } + $null = $updatedContent.Append($content, $position, $content.Length - $position) + + $updated = $false + if ($PSCmdlet.ShouldProcess($file.FullName, "Update $updates GitHub Action SHA pin(s)")) { + $encoding = [System.Text.UTF8Encoding]::new($hasUtf8Bom) + [System.IO.File]::WriteAllText($file.FullName, $updatedContent.ToString(), $encoding) + $updated = $true + Write-Verbose "Updated $updates action reference(s) in $($file.FullName)." + } + + [pscustomobject]@{ + Path = $file.FullName + Updates = $updates + Updated = $updated + } + } +} + +if ($MyInvocation.InvocationName -ne '.') { + Update-GitHubActionPin -RepositoryPath $RepositoryPath -ApiBaseUri $ApiBaseUri +} diff --git a/tests/Update-GitHubActionPin.Tests.ps1 b/tests/Update-GitHubActionPin.Tests.ps1 new file mode 100644 index 0000000..6187a6e --- /dev/null +++ b/tests/Update-GitHubActionPin.Tests.ps1 @@ -0,0 +1,119 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +Describe 'Update-GitHubActionPin' { + BeforeAll { + $script:sourceScript = Join-Path $PSScriptRoot '../.github/scripts/Update-GitHubActionPin.ps1' + . $script:sourceScript + + function New-ActionFixture { + param( + [Parameter(Mandatory)] + [string] $Content, + + [Parameter(Mandatory)] + [string] $NewLine, + + [Parameter()] + [switch] $Utf8Bom + ) + + $root = Join-Path ([System.IO.Path]::GetTempPath()) "docs-action-pin-$([guid]::NewGuid().ToString('N'))" + $workflowDirectory = New-Item -ItemType Directory -Path (Join-Path $root '.github/workflows') + $path = Join-Path $workflowDirectory.FullName 'workflow.yml' + $text = [regex]::Replace($Content, '\r\n|\r|\n', $NewLine) + [System.IO.File]::WriteAllText($path, $text, [System.Text.UTF8Encoding]::new($Utf8Bom)) + + return [pscustomobject]@{ + Root = $root + Path = $path + } + } + + function Set-GitHubApiMock { + Mock -CommandName Invoke-RestMethod -MockWith { + param([string] $Uri) + + if ($Uri -match '/releases/latest$') { + return [pscustomobject]@{ tag_name = 'v2.0.0' } + } + if ($Uri -match '/git/ref/tags/v2.0.0$') { + return [pscustomobject]@{ + object = [pscustomobject]@{ type = 'tag'; sha = '1111111111111111111111111111111111111111' } + } + } + if ($Uri -match '/git/tags/1111111111111111111111111111111111111111$') { + return [pscustomobject]@{ + object = [pscustomobject]@{ type = 'commit'; sha = '2222222222222222222222222222222222222222' } + } + } + + throw "Unexpected GitHub API request: $Uri" + } + } + } + + AfterEach { + if ($fixture -and (Test-Path -LiteralPath $fixture.Root)) { + Remove-Item -LiteralPath $fixture.Root -Recurse -Force + } + } + + It 'updates annotated-tag releases, composite action subpaths, and missing tag comments' { + $fixture = New-ActionFixture -NewLine "`r`n" -Utf8Bom -Content @' +jobs: + build: + steps: + - uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1 + - uses: octo/example/setup@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + - uses: ./local-action + - uses: docker://alpine:3.20 +'@ + Set-GitHubApiMock + + $result = Update-GitHubActionPin -RepositoryPath $fixture.Root -ApiBaseUri 'https://api.example.test' + $bytes = [System.IO.File]::ReadAllBytes($fixture.Path) + $content = [System.IO.File]::ReadAllText($fixture.Path) + + $result.Updates | Should -Be 2 + $result.Updated | Should -BeTrue + $content | Should -Match 'actions/checkout@2222222222222222222222222222222222222222 # v2\.0\.0' + $content | Should -Match 'octo/example/setup@2222222222222222222222222222222222222222 # v2\.0\.0' + $content | Should -Match 'uses: \./local-action' + $content | Should -Match 'uses: docker://alpine:3\.20' + ($bytes[0..2] -join ',') | Should -Be '239,187,191' + $content.Replace("`r`n", '') | Should -Not -Match '[\r\n]' + Should -Invoke -CommandName Invoke-RestMethod -Times 6 + } + + It 'does not write changes when WhatIf is specified' { + $fixture = New-ActionFixture -NewLine "`n" -Content @' +jobs: + build: + steps: + - uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +'@ + $before = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($fixture.Path)) + Set-GitHubApiMock + + $result = Update-GitHubActionPin -RepositoryPath $fixture.Root -ApiBaseUri 'https://api.example.test' -WhatIf + + $result.Updated | Should -BeFalse + [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($fixture.Path)) | Should -BeExactly $before + } + + It 'fails explicitly when GitHub returns no latest stable release tag' { + $fixture = New-ActionFixture -NewLine "`n" -Content @' +jobs: + build: + steps: + - uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +'@ + Mock -CommandName Invoke-RestMethod -MockWith { + [pscustomobject]@{ tag_name = '' } + } + + { + Update-GitHubActionPin -RepositoryPath $fixture.Root -ApiBaseUri 'https://api.example.test' + } | Should -Throw "*no latest stable release tag for action repository 'actions/checkout'*" + } +} From d29bcba6723f1d4ed6007d6b748489ed792364f4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 12:43:17 +0200 Subject: [PATCH 2/4] Avoid rewriting current action pins Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Update-GitHubActionPin.ps1 | 16 ++++++++++++---- tests/Update-GitHubActionPin.Tests.ps1 | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/scripts/Update-GitHubActionPin.ps1 b/.github/scripts/Update-GitHubActionPin.ps1 index 9becf0b..58c9737 100644 --- a/.github/scripts/Update-GitHubActionPin.ps1 +++ b/.github/scripts/Update-GitHubActionPin.ps1 @@ -37,8 +37,7 @@ None. This script does not accept pipeline input. .OUTPUTS - [pscustomobject] with Path, Updates, and Updated properties for every YAML file that - contains a supported action reference. + [pscustomobject] with Path, Updates, and Updated properties for every changed YAML file. #> [OutputType([pscustomobject])] [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] @@ -238,12 +237,21 @@ function Update-GitHubActionPin { foreach ($match in $referenceMatches) { $replacement = Get-GitHubActionReplacement -Match $match -ReleaseCache $releaseCache -ApiBaseUri $ApiBaseUri -Headers $headers $null = $updatedContent.Append($content, $position, $match.Index - $position) - $null = $updatedContent.Append($replacement) + if ($replacement -cne $match.Value) { + $null = $updatedContent.Append($replacement) + $updates++ + } else { + $null = $updatedContent.Append($match.Value) + } $position = $match.Index + $match.Length - $updates++ } $null = $updatedContent.Append($content, $position, $content.Length - $position) + if ($updates -eq 0) { + Write-Verbose "All action references are current in $($file.FullName)." + continue + } + $updated = $false if ($PSCmdlet.ShouldProcess($file.FullName, "Update $updates GitHub Action SHA pin(s)")) { $encoding = [System.Text.UTF8Encoding]::new($hasUtf8Bom) diff --git a/tests/Update-GitHubActionPin.Tests.ps1 b/tests/Update-GitHubActionPin.Tests.ps1 index 6187a6e..129f978 100644 --- a/tests/Update-GitHubActionPin.Tests.ps1 +++ b/tests/Update-GitHubActionPin.Tests.ps1 @@ -101,6 +101,22 @@ jobs: [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($fixture.Path)) | Should -BeExactly $before } + It 'does not rewrite an action pin that already matches the latest release' { + $fixture = New-ActionFixture -NewLine "`n" -Content @' +jobs: + build: + steps: + - uses: actions/checkout@2222222222222222222222222222222222222222 # v2.0.0 +'@ + $before = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($fixture.Path)) + Set-GitHubApiMock + + $result = Update-GitHubActionPin -RepositoryPath $fixture.Root -ApiBaseUri 'https://api.example.test' + + $result | Should -BeNullOrEmpty + [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($fixture.Path)) | Should -BeExactly $before + } + It 'fails explicitly when GitHub returns no latest stable release tag' { $fixture = New-ActionFixture -NewLine "`n" -Content @' jobs: From 3144f1fab290265a948bef0d0d102d89d9df51c4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 12:43:23 +0200 Subject: [PATCH 3/4] Document GitHub Action pin synchronization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/docs/Coding-Standards/GitHub-Actions.md | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/docs/Coding-Standards/GitHub-Actions.md b/src/docs/Coding-Standards/GitHub-Actions.md index 202f5df..daebaad 100644 --- a/src/docs/Coding-Standards/GitHub-Actions.md +++ b/src/docs/Coding-Standards/GitHub-Actions.md @@ -67,6 +67,34 @@ The full mechanism — schedule, cooldown, labels, and auto-merge policy — is [Dependency Updates](../Capabilities/dependency-updates/design.md) capability; this section is the Actions-specific view of it. +### Audit and synchronize pins manually + +[Dependabot](https://docs.github.com/code-security/dependabot/dependabot-version-updates) remains the ongoing updater for the `github-actions` ecosystem. It proposes reviewable pull requests as releases are published, applies the configured cooldown and labels, and is the normal way a repository stays current. + +Use the reusable [`Update-GitHubActionPin.ps1`](https://github.com/MSXOrg/docs/blob/main/.github/scripts/Update-GitHubActionPin.ps1) utility for an audit or a deliberate manual synchronization: for example, when onboarding an existing repository, reconciling a repository after a Dependabot outage, or checking proposed changes before an update pull request is opened. It is not a replacement for enabling Dependabot. + +The script needs PowerShell 7, network access to the GitHub REST API, and a target repository with a `.github` directory. Public actions can be resolved anonymously; set `GITHUB_TOKEN` or `GH_TOKEN` to raise the API rate limit or to resolve actions that require authentication. The token is sent only as an API request header. + +From this checkout, preview a target repository without changing it: + +```powershell +pwsh .github/scripts/Update-GitHubActionPin.ps1 -RepositoryPath ../service -WhatIf +``` + +Run the same command without `-WhatIf` to write the synchronized pins: + +```powershell +pwsh .github/scripts/Update-GitHubActionPin.ps1 -RepositoryPath ../service +``` + +When the current directory is the target repository, the path is optional: + +```powershell +pwsh ../docs/.github/scripts/Update-GitHubActionPin.ps1 -WhatIf +``` + +The utility recursively scans `.github` YAML files and updates only `uses:` references in the form `owner/repository[/subpath]@<40-character-SHA>`. It resolves the latest stable release to its commit SHA, including annotated tags, replaces the trailing release-tag comment, and adds that comment when omitted. It preserves UTF-8 BOM state and existing line endings. Local (`./...`) and Docker (`docker://...`) references, mutable tags or branches, non-YAML files, and expressions outside this shape are intentionally outside its scope. An action with no resolvable stable release or tag-to-commit mapping stops the run with an explicit error so a partial audit does not appear successful. + ## Grant least-privilege permissions - **Set `permissions:` explicitly.** Never rely on the default token scope. From 7425ecefcf57dbd6d7af01c2c3e781680c170a5c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:34:02 +0200 Subject: [PATCH 4/4] Suppress test setup lint warnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Update-GitHubActionPin.Tests.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Update-GitHubActionPin.Tests.ps1 b/tests/Update-GitHubActionPin.Tests.ps1 index 129f978..56dfb31 100644 --- a/tests/Update-GitHubActionPin.Tests.ps1 +++ b/tests/Update-GitHubActionPin.Tests.ps1 @@ -1,5 +1,11 @@ #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Pester setup helpers must create disposable fixtures and register mocks without WhatIf semantics.' +)] +param() + Describe 'Update-GitHubActionPin' { BeforeAll { $script:sourceScript = Join-Path $PSScriptRoot '../.github/scripts/Update-GitHubActionPin.ps1'