dadba2f4c8
The publish job failed on PR #19 with a 409 for version 2026.7.30.2309, but that version is live on the gallery (created 23:10:04) - the push landed and the client still surfaced an error, so the retry collided with the upload that had just succeeded. The run went red over a publish that actually worked. The step now checks the gallery before pushing and skips when the version is already there, and on a publish error it re-checks before failing. This mirrors the release job, which already looks for an existing tag and skips. Version comparison normalizes each segment the way NuGet does, since the manifest carries zero-padded segments (2026.07.30.2309) while the gallery lists the stripped form (2026.7.30.2309); comparing the raw strings would never match and the guard would never fire. Verified: both workflows still parse as YAML with the same three jobs, the normalizer reproduces the gallery form for four published versions, and a live lookup confirms the guard would have exited 0 on the run that failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
384 lines
17 KiB
YAML
384 lines
17 KiB
YAML
name: Publish to PowerShell Gallery
|
|
|
|
on:
|
|
pull_request:
|
|
types: [closed]
|
|
branches: [main]
|
|
|
|
jobs:
|
|
build:
|
|
if: github.event.pull_request.merged == true
|
|
runs-on: powershell-linux
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Verify host prerequisites (pwsh, dotnet)
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
$missing = @()
|
|
if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { $missing += 'pwsh' }
|
|
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { $missing += 'dotnet' }
|
|
if ($missing.Count -gt 0) {
|
|
throw "Host runner is missing required tool(s): $($missing -join ', '). Provision them on the runner host."
|
|
}
|
|
Write-Host ("pwsh: " + (pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'))
|
|
Write-Host ("dotnet: " + (dotnet --version))
|
|
Write-Host '--- dotnet --info ---'
|
|
dotnet --info
|
|
Write-Host '--- disk free ---'
|
|
df -h .
|
|
Write-Host '--- memory ---'
|
|
free -m
|
|
|
|
- name: Restore NuGet packages
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
Write-Host '==> dotnet restore src/PSInfisicalAPI/PSInfisicalAPI.csproj'
|
|
dotnet restore src/PSInfisicalAPI/PSInfisicalAPI.csproj --verbosity normal
|
|
if ($LASTEXITCODE -ne 0) { throw "Restore of PSInfisicalAPI.csproj failed with exit code $LASTEXITCODE" }
|
|
Write-Host '==> dotnet restore src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj'
|
|
dotnet restore src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj --verbosity normal
|
|
if ($LASTEXITCODE -ne 0) { throw "Restore of PSInfisicalAPI.Tests.csproj failed with exit code $LASTEXITCODE" }
|
|
|
|
- name: Build module
|
|
shell: pwsh
|
|
run: ./build.ps1
|
|
|
|
- name: Validate module manifest
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
$manifestPath = Join-Path $PWD 'Module/PSInfisicalAPI/PSInfisicalAPI.psd1'
|
|
$manifest = Test-ModuleManifest -Path $manifestPath
|
|
Write-Host "Manifest OK: $($manifest.Name) $($manifest.Version)"
|
|
|
|
- name: Upload module artifact
|
|
uses: christopherhx/gitea-upload-artifact@v4
|
|
with:
|
|
name: PSInfisicalAPI-module
|
|
path: Module/PSInfisicalAPI
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
release:
|
|
needs: build
|
|
if: ${{ success() && github.event.pull_request.merged == true }}
|
|
runs-on: powershell-linux
|
|
outputs:
|
|
version: ${{ steps.meta.outputs.version }}
|
|
tag: ${{ steps.meta.outputs.tag }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Verify host prerequisites (pwsh)
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) {
|
|
throw "Host runner is missing required tool: pwsh. Provision it on the runner host."
|
|
}
|
|
Write-Host ("pwsh: " + (pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'))
|
|
|
|
- name: Download module artifact
|
|
uses: christopherhx/gitea-download-artifact@v4
|
|
with:
|
|
name: PSInfisicalAPI-module
|
|
path: Module/PSInfisicalAPI
|
|
|
|
- name: Resolve module version and tag
|
|
id: meta
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
$manifestPath = Join-Path $PWD 'Module/PSInfisicalAPI/PSInfisicalAPI.psd1'
|
|
$manifest = Test-ModuleManifest -Path $manifestPath
|
|
$version = $manifest.Version.ToString()
|
|
$tag = $version
|
|
Write-Host "Module version: $version"
|
|
Write-Host "Release tag: $tag"
|
|
"version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
|
"tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
|
|
|
- name: Package module as release asset
|
|
shell: pwsh
|
|
env:
|
|
VERSION: ${{ steps.meta.outputs.version }}
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
$zipPath = Join-Path $PWD "PSInfisicalAPI-$($env:VERSION).zip"
|
|
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
|
|
Compress-Archive -Path 'Module/PSInfisicalAPI/*' -DestinationPath $zipPath -Force
|
|
Write-Host "Created: $zipPath ($([math]::Round((Get-Item $zipPath).Length / 1KB, 1)) KB)"
|
|
|
|
- name: Create Gitea release
|
|
shell: pwsh
|
|
env:
|
|
GITEA_TOKEN: ${{ github.token }}
|
|
API_URL: ${{ github.api_url }}
|
|
REPO: ${{ github.repository }}
|
|
TAG: ${{ steps.meta.outputs.tag }}
|
|
VERSION: ${{ steps.meta.outputs.version }}
|
|
COMMIT_SHA: ${{ github.sha }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
|
SERVER_URL: ${{ github.server_url }}
|
|
RUN_ID: ${{ github.run_id }}
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
Set-StrictMode -Version Latest
|
|
trap { Write-Host "==> RELEASE STEP FAILED: $($_ | Out-String)"; Write-Host ($_.ScriptStackTrace); exit 1 }
|
|
|
|
Write-Host "==> [1/8] Validating inputs"
|
|
Write-Host " TAG=$($env:TAG)"
|
|
Write-Host " VERSION=$($env:VERSION)"
|
|
Write-Host " REPO=$($env:REPO)"
|
|
Write-Host " API_URL=$($env:API_URL)"
|
|
Write-Host " SERVER_URL=$($env:SERVER_URL)"
|
|
Write-Host " PR_NUMBER=$($env:PR_NUMBER)"
|
|
Write-Host " RUN_ID=$($env:RUN_ID)"
|
|
if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) { throw "github.token is empty." }
|
|
if ([string]::IsNullOrWhiteSpace($env:TAG)) { throw "TAG is empty." }
|
|
if ([string]::IsNullOrWhiteSpace($env:VERSION)) { throw "VERSION is empty." }
|
|
if ([string]::IsNullOrWhiteSpace($env:API_URL)) { throw "API_URL is empty." }
|
|
if ([string]::IsNullOrWhiteSpace($env:REPO)) { throw "REPO is empty." }
|
|
if ([string]::IsNullOrWhiteSpace($env:COMMIT_SHA)) { throw "COMMIT_SHA is empty." }
|
|
|
|
Write-Host "==> [2/8] Deriving metadata"
|
|
$shortSha = $env:COMMIT_SHA.Substring(0, [Math]::Min(12, $env:COMMIT_SHA.Length))
|
|
$buildUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
$runUrl = "$($env:SERVER_URL)/$($env:REPO)/actions/runs/$($env:RUN_ID)"
|
|
$prUrl = "$($env:SERVER_URL)/$($env:REPO)/pulls/$($env:PR_NUMBER)"
|
|
Write-Host " shortSha=$shortSha"
|
|
|
|
Write-Host "==> [3/8] Extracting CHANGELOG section"
|
|
$changelogSection = ''
|
|
if (Test-Path 'CHANGELOG.md') {
|
|
$lines = [System.IO.File]::ReadAllLines('CHANGELOG.md')
|
|
$start = -1; $end = $lines.Length
|
|
for ($i = 0; $i -lt $lines.Length; $i++) {
|
|
if ($lines[$i] -match "^##\s+$([regex]::Escape($env:VERSION))\s*$") { $start = $i + 1; continue }
|
|
if ($start -ge 0 -and $lines[$i] -match '^##\s+') { $end = $i; break }
|
|
}
|
|
if ($start -ge 0) {
|
|
$changelogSection = ($lines[$start..($end - 1)] -join "`n").Trim()
|
|
}
|
|
}
|
|
Write-Host " CHANGELOG section length: $($changelogSection.Length) chars"
|
|
|
|
Write-Host "==> [4/8] Building release body"
|
|
$changelogText = if ($changelogSection) { $changelogSection } else { '_No CHANGELOG section found for this version._' }
|
|
$sb = New-Object System.Text.StringBuilder
|
|
[void]$sb.AppendLine("**PSInfisicalAPI $($env:VERSION)**")
|
|
[void]$sb.AppendLine('')
|
|
[void]$sb.AppendLine('| Field | Value |')
|
|
[void]$sb.AppendLine('| --- | --- |')
|
|
[void]$sb.AppendLine("| Version | ``$($env:VERSION)`` |")
|
|
[void]$sb.AppendLine("| Tag | ``$($env:TAG)`` |")
|
|
[void]$sb.AppendLine("| Commit | [``$shortSha``]($($env:SERVER_URL)/$($env:REPO)/commit/$($env:COMMIT_SHA)) |")
|
|
[void]$sb.AppendLine("| Built (UTC) | $buildUtc |")
|
|
[void]$sb.AppendLine("| Merged PR | [#$($env:PR_NUMBER) $($env:PR_TITLE)]($prUrl) by @$($env:PR_AUTHOR) |")
|
|
[void]$sb.AppendLine("| Workflow run | [$($env:RUN_ID)]($runUrl) |")
|
|
[void]$sb.AppendLine('')
|
|
[void]$sb.AppendLine('## Changes')
|
|
[void]$sb.AppendLine($changelogText)
|
|
[void]$sb.AppendLine('')
|
|
[void]$sb.AppendLine('## Install')
|
|
[void]$sb.AppendLine('```powershell')
|
|
[void]$sb.AppendLine("Install-Module -Name PSInfisicalAPI -RequiredVersion $($env:VERSION) -Scope CurrentUser")
|
|
[void]$sb.AppendLine('```')
|
|
$body = $sb.ToString()
|
|
Write-Host " body length: $($body.Length) chars"
|
|
|
|
$headers = @{
|
|
Authorization = "token $($env:GITEA_TOKEN)"
|
|
Accept = 'application/json'
|
|
}
|
|
$createUri = "$($env:API_URL)/repos/$($env:REPO)/releases"
|
|
|
|
Write-Host "==> [5/8] Checking for existing release tag: $createUri/tags/$($env:TAG)"
|
|
$existing = $null
|
|
try {
|
|
$existing = Invoke-RestMethod -Method Get -Headers $headers `
|
|
-Uri "$createUri/tags/$($env:TAG)" -ErrorAction Stop
|
|
} catch {
|
|
$status = $null
|
|
try { $status = $_.Exception.Response.StatusCode.value__ } catch { }
|
|
if ($status -ne 404) {
|
|
Write-Host " Lookup failed (status=$status): $($_.Exception.Message)"
|
|
throw
|
|
}
|
|
Write-Host " No existing release (404)."
|
|
}
|
|
if ($existing) {
|
|
Write-Host " Release tag '$($env:TAG)' already exists (id=$($existing.id)); skipping creation."
|
|
return
|
|
}
|
|
|
|
Write-Host "==> [6/8] Creating release"
|
|
$payload = @{
|
|
tag_name = $env:TAG
|
|
target_commitish = $env:COMMIT_SHA
|
|
name = "PSInfisicalAPI $($env:VERSION)"
|
|
body = $body
|
|
draft = $false
|
|
prerelease = $false
|
|
} | ConvertTo-Json -Depth 4
|
|
Write-Host " payload bytes: $([System.Text.Encoding]::UTF8.GetByteCount($payload))"
|
|
|
|
$release = Invoke-RestMethod -Method Post -Uri $createUri -Headers $headers `
|
|
-ContentType 'application/json' -Body $payload
|
|
Write-Host " Created release id=$($release.id) at $($release.html_url)"
|
|
|
|
Write-Host "==> [7/8] Locating release asset"
|
|
$assetPath = Join-Path $PWD "PSInfisicalAPI-$($env:VERSION).zip"
|
|
if (-not (Test-Path $assetPath)) { throw "Release asset not found at: $assetPath" }
|
|
$fileBytes = [System.IO.File]::ReadAllBytes($assetPath)
|
|
Write-Host " Asset: $assetPath ($([math]::Round($fileBytes.Length / 1KB, 1)) KB)"
|
|
|
|
Write-Host "==> [8/8] Uploading asset"
|
|
$uploadUri = "$createUri/$($release.id)/assets?name=PSInfisicalAPI-$($env:VERSION).zip"
|
|
Invoke-RestMethod -Method Post -Uri $uploadUri -Headers $headers `
|
|
-ContentType 'application/zip' -Body $fileBytes | Out-Null
|
|
Write-Host "==> Done: uploaded PSInfisicalAPI-$($env:VERSION).zip"
|
|
|
|
publish:
|
|
needs: release
|
|
if: ${{ success() && github.event.pull_request.merged == true }}
|
|
runs-on: powershell-linux
|
|
steps:
|
|
- name: Verify host prerequisites (pwsh)
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) {
|
|
throw "Host runner is missing required tool: pwsh. Provision it on the runner host."
|
|
}
|
|
Write-Host ("pwsh: " + (pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'))
|
|
|
|
- name: Download module artifact
|
|
uses: christopherhx/gitea-download-artifact@v4
|
|
with:
|
|
name: PSInfisicalAPI-module
|
|
path: Module/PSInfisicalAPI
|
|
|
|
- name: Bootstrap Microsoft.PowerShell.PSResourceGet
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
if (-not (Get-Module -ListAvailable -Name Microsoft.PowerShell.PSResourceGet)) {
|
|
throw "Microsoft.PowerShell.PSResourceGet is not installed on the host runner. Provision it (Install-Module Microsoft.PowerShell.PSResourceGet -Scope AllUsers)."
|
|
}
|
|
Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction Stop
|
|
|
|
$storeDir = Join-Path $HOME '.local/share/PSResourceGet'
|
|
if (-not (Test-Path $storeDir)) {
|
|
Write-Host "==> Creating PSResourceGet store directory: $storeDir"
|
|
New-Item -ItemType Directory -Path $storeDir -Force | Out-Null
|
|
}
|
|
|
|
$existing = Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue
|
|
if (-not $existing) {
|
|
Write-Host "==> Registering PSGallery repository"
|
|
Register-PSResourceRepository -PSGallery -Trusted -ErrorAction Stop
|
|
} else {
|
|
Write-Host "==> PSGallery already registered; ensuring Trusted + ApiVersion v2"
|
|
Set-PSResourceRepository -Name PSGallery -Trusted -ApiVersion v2 -ErrorAction Stop
|
|
}
|
|
Get-PSResourceRepository -Name PSGallery | Format-Table Name,Uri,Trusted,ApiVersion
|
|
|
|
- name: Verify PowerShell Gallery API key is configured
|
|
shell: pwsh
|
|
env:
|
|
PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
|
|
run: |
|
|
if ([string]::IsNullOrWhiteSpace($env:PSGALLERY_API_KEY)) {
|
|
throw "Repository secret 'PSGALLERY_API_KEY' is not configured."
|
|
}
|
|
|
|
- name: Re-validate downloaded module manifest
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
$manifestPath = Join-Path $PWD 'Module/PSInfisicalAPI/PSInfisicalAPI.psd1'
|
|
$manifest = Test-ModuleManifest -Path $manifestPath
|
|
Write-Host "Manifest OK: $($manifest.Name) $($manifest.Version)"
|
|
|
|
- name: Publish to PowerShell Gallery
|
|
shell: pwsh
|
|
env:
|
|
PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
$moduleDir = Join-Path $PWD 'Module/PSInfisicalAPI'
|
|
$manifest = Test-ModuleManifest -Path (Join-Path $moduleDir 'PSInfisicalAPI.psd1')
|
|
$version = $manifest.Version.ToString()
|
|
|
|
# NuGet strips leading zeros from each segment, so the manifest's 2026.07.30.2309 is listed on the
|
|
# gallery as 2026.7.30.2309. Compare on the normalized form or every lookup misses.
|
|
function Get-NormalizedVersion {
|
|
param([string]$Value)
|
|
$parts = $Value -split '\.'
|
|
$normalized = foreach ($part in $parts) {
|
|
$number = 0
|
|
if ([int]::TryParse($part, [ref]$number)) { $number.ToString([System.Globalization.CultureInfo]::InvariantCulture) } else { $part }
|
|
}
|
|
return ($normalized -join '.')
|
|
}
|
|
|
|
function Test-PublishedVersion {
|
|
param([string]$Normalized)
|
|
$uri = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PSInfisicalAPI'&`$select=Version"
|
|
try {
|
|
$feed = Invoke-RestMethod -Uri $uri -TimeoutSec 120
|
|
} catch {
|
|
Write-Host "==> Could not query the gallery for existing versions: $($_.Exception.Message)"
|
|
return $false
|
|
}
|
|
|
|
foreach ($entry in @($feed)) {
|
|
$candidate = $entry.properties.Version
|
|
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
|
if ((Get-NormalizedVersion -Value $candidate) -eq $Normalized) { return $true }
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
$normalizedVersion = Get-NormalizedVersion -Value $version
|
|
Write-Host "==> Module version : $version (gallery form: $normalizedVersion)"
|
|
Write-Host "==> Publishing from: $moduleDir"
|
|
|
|
if (Test-PublishedVersion -Normalized $normalizedVersion) {
|
|
Write-Host "==> Version $normalizedVersion is already on the PowerShell Gallery; nothing to publish."
|
|
exit 0
|
|
}
|
|
|
|
try {
|
|
Publish-PSResource `
|
|
-Path $moduleDir `
|
|
-Repository PSGallery `
|
|
-ApiKey $env:PSGALLERY_API_KEY `
|
|
-Verbose
|
|
Write-Host "==> Published $normalizedVersion to the PowerShell Gallery."
|
|
} catch {
|
|
# A push can be accepted by the gallery and still surface as an error here; when that happens the
|
|
# retry comes back as 409. Re-check before failing the run over an upload that actually landed.
|
|
Write-Host "==> Publish-PSResource reported: $($_.Exception.Message)"
|
|
Start-Sleep -Seconds 15
|
|
if (Test-PublishedVersion -Normalized $normalizedVersion) {
|
|
Write-Host "==> Version $normalizedVersion is present on the gallery; treating the publish as successful."
|
|
exit 0
|
|
}
|
|
|
|
throw
|
|
}
|