From 6318d06362adf4dc91b9e2d81aed9053514ace4f Mon Sep 17 00:00:00 2001 From: GraceSolutions Date: Wed, 10 Jun 2026 16:54:22 -0400 Subject: [PATCH] Add GitHub Actions workflow for PowerShell Gallery publish Mirrors the Gitea workflow with GitHub-specific adaptations: ubuntu-latest runner, actions/upload-artifact and actions/download-artifact v4, Bearer auth with X-GitHub-Api-Version header, /pull/ URL path, upload_url URI template handling on uploads.github.com, contents:write permission on the release job, and on-demand Install-Module of Microsoft.PowerShell.PSResourceGet for CurrentUser. --- .github/workflows/publish-psgallery.yml | 328 ++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 .github/workflows/publish-psgallery.yml diff --git a/.github/workflows/publish-psgallery.yml b/.github/workflows/publish-psgallery.yml new file mode 100644 index 0000000..e6add7c --- /dev/null +++ b/.github/workflows/publish-psgallery.yml @@ -0,0 +1,328 @@ +name: Publish to PowerShell Gallery + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + build: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + 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: actions/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: ubuntu-latest + permissions: + contents: write + 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: actions/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 GitHub release + shell: pwsh + env: + GITHUB_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:GITHUB_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)/pull/$($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 = "Bearer $($env:GITHUB_TOKEN)" + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + $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" + # GitHub returns a URI Template in upload_url (e.g. "https://uploads.github.com/.../assets{?name,label}"). + # Strip the template suffix and append the asset name query. + $uploadBase = ($release.upload_url -replace '\{.*\}$', '') + $uploadUri = "$uploadBase`?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: ubuntu-latest + 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: actions/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)) { + Write-Host "==> Installing Microsoft.PowerShell.PSResourceGet for CurrentUser" + Install-Module -Name Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + } + Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction Stop + + $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' + Write-Host "Publishing module from: $moduleDir" + Publish-PSResource ` + -Path $moduleDir ` + -Repository PSGallery ` + -ApiKey $env:PSGALLERY_API_KEY ` + -Verbose