CHANGELOG: rewrite 1.12.0 notes in plainer, user-facing language #6
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| tags: | |
| - 'v*.*.*' | |
| jobs: | |
| release: | |
| runs-on: windows-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: | | |
| 8.0.x | |
| 9.0.x | |
| - name: Derive version from tag | |
| id: ver | |
| shell: pwsh | |
| run: | | |
| $tag = "${env:GITHUB_REF_NAME}" | |
| $v = $tag.TrimStart('v') | |
| "version=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append | |
| "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append | |
| - name: Verify version consistency across psd1 and csproj | |
| shell: pwsh | |
| run: | | |
| $expected = '${{ steps.ver.outputs.version }}' | |
| $expectedCsproj = "$expected.0" # csproj carries 4-part assembly version, psd1 carries 3-part | |
| # psd1 | |
| $data = Import-PowerShellDataFile Staging/PowerShell.MCP.psd1 | |
| if ($data.ModuleVersion -ne $expected) { | |
| throw "Staging/PowerShell.MCP.psd1 ModuleVersion $($data.ModuleVersion) != tag v$expected" | |
| } | |
| # PowerShell.MCP.csproj | |
| [xml]$mainProj = Get-Content 'PowerShell.MCP/PowerShell.MCP.csproj' | |
| $mainVer = $mainProj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 | |
| if ($mainVer -ne $expectedCsproj) { | |
| throw "PowerShell.MCP.csproj Version $mainVer != $expectedCsproj" | |
| } | |
| # PowerShell.MCP.Proxy.csproj | |
| [xml]$proxyProj = Get-Content 'PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj' | |
| $proxyVer = $proxyProj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 | |
| if ($proxyVer -ne $expectedCsproj) { | |
| throw "PowerShell.MCP.Proxy.csproj Version $proxyVer != $expectedCsproj" | |
| } | |
| Write-Host "All three version sources match: psd1=$expected, csproj=$expectedCsproj" | |
| - name: Syntax check PowerShell sources (parse + AMSI #50 IEX guard) | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $failed = $false | |
| $files = Get-ChildItem -Recurse -Include *.ps1,*.psm1 -Path ` | |
| 'Staging','PowerShell.MCP/Resources','Build-AllPlatforms.ps1' -ErrorAction SilentlyContinue | |
| foreach ($f in $files) { | |
| $errors = $null | |
| [void][System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | |
| if ($errors) { | |
| $failed = $true | |
| foreach ($e in $errors) { Write-Host "::error file=$($f.FullName),line=$($e.Extent.StartLineNumber)::$($e.Message)" } | |
| } | |
| } | |
| $engine = 'PowerShell.MCP/Resources/MCPPollingEngine.ps1' | |
| if (Select-String -Path $engine -Pattern 'Invoke-Expression' -Quiet) { | |
| $failed = $true | |
| Write-Host "::error file=$engine::Invoke-Expression is banned in the polling engine (AMSI #50 regression guard)" | |
| } | |
| if ($failed) { throw 'Syntax check failed before release.' } | |
| Write-Host 'Syntax check passed.' | |
| - name: Extract release notes for this version (fail fast) | |
| id: notes | |
| shell: pwsh | |
| run: | | |
| # Pulled ahead of build/publish so a tag with no matching CHANGELOG | |
| # section fails in seconds, BEFORE the irreversible PSGallery publish | |
| # (it used to run after publish, which RELEASE.md wrongly described as | |
| # "fails before publish"). The output is consumed later by the GitHub | |
| # Release step. | |
| $version = '${{ steps.ver.outputs.version }}' | |
| $lines = Get-Content CHANGELOG.md | |
| $inSection = $false | |
| $section = New-Object System.Collections.Generic.List[string] | |
| foreach ($line in $lines) { | |
| if ($line -match '^# Version:\s*(\S+)') { | |
| if ($matches[1] -eq $version) { $inSection = $true; continue } | |
| elseif ($inSection) { break } | |
| } | |
| if ($inSection) { $section.Add($line) } | |
| } | |
| $body = ($section -join "`n").Trim() | |
| if ([string]::IsNullOrWhiteSpace($body)) { | |
| throw "No release notes section found for version $version in CHANGELOG.md" | |
| } | |
| $notesPath = Join-Path $env:RUNNER_TEMP 'release-notes.md' | |
| Set-Content -Path $notesPath -Value $body -Encoding UTF8 | |
| "notes_path=$notesPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append | |
| Write-Host "Release notes for $version found ($($section.Count) lines)." | |
| - name: Restore | |
| shell: pwsh | |
| run: | | |
| dotnet restore PowerShell.MCP/PowerShell.MCP.csproj --source https://api.nuget.org/v3/index.json --ignore-failed-sources | |
| dotnet restore PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj --source https://api.nuget.org/v3/index.json --ignore-failed-sources | |
| - name: Build PowerShell.MCP.dll (net8.0) | |
| shell: pwsh | |
| run: | | |
| dotnet build PowerShell.MCP/PowerShell.MCP.csproj -c Release -f net8.0 --no-restore | |
| - name: Run unit test suite (release gate) | |
| shell: pwsh | |
| run: | | |
| # Release gate: the exact commit being published must pass the full | |
| # xUnit suite on BOTH runtimes (net8.0 = the shipped DLL target, | |
| # net9.0 = the Proxy target). PSGallery publishes are irreversible | |
| # (a bad version can only be unlisted, never replaced), so we never | |
| # rely on the maintainer having run tests locally — CI re-proves it | |
| # here, before the slow multi-RID publish and well before publish. | |
| # dotnet test restores+builds the Tests project itself (the upstream | |
| # Restore step only covered the two shipping projects). | |
| dotnet test Tests/PowerShell.MCP.Tests.csproj -c Release | |
| if ($LASTEXITCODE -ne 0) { throw 'Unit tests failed — aborting release before publish.' } | |
| - name: Publish PowerShell.MCP.Proxy for all RIDs | |
| shell: pwsh | |
| run: | | |
| # Each `dotnet publish -r <rid>` does its OWN RID-specific | |
| # restore (the project.assets.json from the upstream Restore | |
| # step has no per-RID targets, so passing --no-restore here | |
| # would fail with "Assets file ... doesn't have a target for | |
| # 'net9.0/<rid>'"). Letting publish restore is the simplest | |
| # path to a self-contained per-RID build; the time cost | |
| # (4 restores instead of 1) is small relative to the publish | |
| # itself. | |
| foreach ($rid in 'win-x64','linux-x64','osx-x64','osx-arm64') { | |
| $outDir = "PowerShell.MCP.Proxy/bin/Release/net9.0/$rid/publish" | |
| Write-Host "=== Publishing Proxy for $rid ===" | |
| dotnet publish PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj ` | |
| -c Release -r $rid -o $outDir ` | |
| --self-contained | |
| if ($LASTEXITCODE -ne 0) { throw "Proxy publish failed for $rid" } | |
| } | |
| - name: Build help XML (PlatyPS v2) | |
| shell: pwsh | |
| run: | | |
| Install-Module Microsoft.PowerShell.PlatyPS -Scope CurrentUser -Force -AllowClobber | |
| Import-Module Microsoft.PowerShell.PlatyPS | |
| $mdFiles = Get-ChildItem 'PowerShell.MCP/PlatyPS/en-US/*.md' -Exclude 'PowerShell.MCP.md','*.md.bak' | |
| if ($mdFiles.Count -eq 0) { throw 'No PlatyPS markdown files found' } | |
| $helpObjects = $mdFiles | ForEach-Object { Import-MarkdownCommandHelp -Path $_.FullName } | |
| $helpOut = Join-Path $env:RUNNER_TEMP 'help-out' | |
| $null = Export-MamlCommandHelp -CommandHelp $helpObjects -OutputFolder $helpOut -Force | |
| $xml = Get-ChildItem $helpOut -Filter '*.xml' -Recurse | |
| if (-not $xml) { throw 'PlatyPS produced no XML output' } | |
| "helpOut=$helpOut" | Out-File -FilePath $env:GITHUB_ENV -Append | |
| Write-Host "Built $($xml.Count) help XML file(s)" | |
| - name: Assemble module directory | |
| id: stage | |
| shell: pwsh | |
| run: | | |
| $stage = Join-Path $env:RUNNER_TEMP 'PowerShell.MCP' | |
| New-Item -ItemType Directory -Path $stage -Force | Out-Null | |
| New-Item -ItemType Directory -Path (Join-Path $stage 'en-US') -Force | Out-Null | |
| New-Item -ItemType Directory -Path (Join-Path $stage 'licenses') -Force | Out-Null | |
| # DLL + Ude.NetStandard.dll (net8.0). The csproj's CopyUdeNetStandard target | |
| # places Ude.NetStandard.dll into the build output. | |
| $dllOut = 'PowerShell.MCP/bin/Release/net8.0' | |
| Copy-Item (Join-Path $dllOut 'PowerShell.MCP.dll') $stage | |
| Copy-Item (Join-Path $dllOut 'Ude.NetStandard.dll') $stage | |
| # Manifest + psm1 | |
| Copy-Item 'Staging/PowerShell.MCP.psd1' $stage | |
| Copy-Item 'Staging/PowerShell.MCP.psm1' $stage | |
| # Licenses / notices | |
| Copy-Item 'THIRD_PARTY_NOTICES.md' $stage | |
| Copy-Item 'licenses/*' (Join-Path $stage 'licenses') -Recurse | |
| # Proxy binaries (multi-RID) | |
| foreach ($rid in 'win-x64','linux-x64','osx-x64','osx-arm64') { | |
| $ridDir = Join-Path $stage "bin/$rid" | |
| New-Item -ItemType Directory -Path $ridDir -Force | Out-Null | |
| $exeName = if ($rid -like 'win-*') { 'PowerShell.MCP.Proxy.exe' } else { 'PowerShell.MCP.Proxy' } | |
| Copy-Item "PowerShell.MCP.Proxy/bin/Release/net9.0/$rid/publish/$exeName" $ridDir | |
| } | |
| # Help XML | |
| Get-ChildItem $env:helpOut -Filter '*.xml' -Recurse | Copy-Item -Destination (Join-Path $stage 'en-US') | |
| "stage=$stage" | Out-File -FilePath $env:GITHUB_OUTPUT -Append | |
| Write-Host "=== Staged module contents ===" | |
| Get-ChildItem $stage -Recurse | Select-Object FullName | |
| - name: Probe signing secret | |
| id: sign_probe | |
| shell: pwsh | |
| env: | |
| PFX_B64: ${{ secrets.CODE_SIGNING_PFX_BASE64 }} | |
| run: | | |
| $present = -not [string]::IsNullOrEmpty($env:PFX_B64) | |
| "present=$($present.ToString().ToLower())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append | |
| - name: Sign Windows binaries (DLL + Proxy.exe only) | |
| if: steps.sign_probe.outputs.present == 'true' | |
| shell: pwsh | |
| env: | |
| PFX_B64: ${{ secrets.CODE_SIGNING_PFX_BASE64 }} | |
| PFX_PW: ${{ secrets.CODE_SIGNING_PFX_PASSWORD }} | |
| run: | | |
| $pfx = Join-Path $env:RUNNER_TEMP 'codesign.pfx' | |
| [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:PFX_B64)) | |
| $pw = ConvertTo-SecureString $env:PFX_PW -AsPlainText -Force | |
| $cert = Get-PfxCertificate -FilePath $pfx -Password $pw | |
| $stage = '${{ steps.stage.outputs.stage }}' | |
| # Sign ONLY the native Windows binaries. Script files (psd1 / psm1) are | |
| # left unsigned: Install-Module verifies Authenticode on script files and | |
| # would reject a self-signed signature on any machine where the cert is | |
| # not pre-trusted. Ude.NetStandard.dll is a third-party binary we do not | |
| # re-sign. Non-Windows Proxy binaries cannot carry Authenticode signatures. | |
| $files = @( | |
| "$stage/PowerShell.MCP.dll", | |
| "$stage/bin/win-x64/PowerShell.MCP.Proxy.exe" | |
| ) | |
| Set-AuthenticodeSignature -FilePath $files -Certificate $cert ` | |
| -HashAlgorithm SHA256 ` | |
| -TimestampServer http://timestamp.digicert.com ` | |
| -IncludeChain NotRoot | |
| # Self-signed certs yield Status=UnknownError on machines where the cert | |
| # is not in the trust store (GitHub runners don't trust it). That's | |
| # expected — the signature itself is correct, only chain validation needs | |
| # the cert installed on the end-user machine. Verify thumbprint matches + | |
| # reject tamper / missing. | |
| $expected = $cert.Thumbprint | |
| $bad = Get-AuthenticodeSignature $files | Where-Object { | |
| $_.SignerCertificate.Thumbprint -ne $expected -or | |
| $_.Status -notin @('Valid','UnknownError') | |
| } | |
| if ($bad) { | |
| $bad | Format-Table Status, StatusMessage, Path | |
| throw 'One or more Windows binary signatures are missing, tampered, or signed by the wrong cert.' | |
| } | |
| - name: Guard — Windows binaries must be signed for a tagged release | |
| if: steps.sign_probe.outputs.present != 'true' | |
| shell: pwsh | |
| run: | | |
| throw 'CODE_SIGNING_PFX_BASE64 is not configured. Tagged releases must be signed. Add the secret and re-run, or delete the tag.' | |
| - name: Assert signing distribution (only DLL + win-x64 Proxy.exe) | |
| shell: pwsh | |
| run: | | |
| $stage = '${{ steps.stage.outputs.stage }}' | |
| $shouldBeSigned = @( | |
| "$stage/PowerShell.MCP.dll", | |
| "$stage/bin/win-x64/PowerShell.MCP.Proxy.exe" | |
| ) | |
| $shouldBeUnsigned = @( | |
| "$stage/PowerShell.MCP.psd1", | |
| "$stage/PowerShell.MCP.psm1", | |
| "$stage/Ude.NetStandard.dll" | |
| ) | |
| foreach ($f in $shouldBeSigned) { | |
| $s = Get-AuthenticodeSignature $f | |
| if ($s.Status -eq 'NotSigned') { throw "Expected signed but NotSigned: $f" } | |
| } | |
| foreach ($f in $shouldBeUnsigned) { | |
| $s = Get-AuthenticodeSignature $f | |
| if ($s.Status -ne 'NotSigned') { throw "Expected unsigned but $($s.Status): $f" } | |
| } | |
| Write-Host 'Signing distribution OK: DLL + win-x64 Proxy.exe signed; script files + Ude unsigned.' | |
| - name: Smoke test — import the assembled module (last gate before publish) | |
| shell: pwsh | |
| run: | | |
| # Final gate before the irreversible PSGallery publish: import the EXACT | |
| # staged + signed artifact (not a separately-built copy) and prove it | |
| # loads, reports the tagged version, exposes its commands, and can | |
| # resolve its bundled proxy. Catches a broken manifest, a missing file, | |
| # or a signed DLL that won't load — failures the C# unit suite cannot | |
| # see because they only exist in the assembled package. | |
| $ErrorActionPreference = 'Stop' | |
| $stage = '${{ steps.stage.outputs.stage }}' | |
| $expected = '${{ steps.ver.outputs.version }}' | |
| Import-Module (Join-Path $stage 'PowerShell.MCP.psd1') -Force -Verbose | |
| $mod = Get-Module PowerShell.MCP | |
| if (-not $mod) { throw 'Import-Module produced no loaded module.' } | |
| if ($mod.Version.ToString() -ne $expected) { | |
| throw "Loaded module version $($mod.Version) != tag v$expected" | |
| } | |
| Write-Host "Imported PowerShell.MCP $($mod.Version) from the staged package." | |
| # Representative command surface, including the #50 graceful-degradation | |
| # entry point (Restart-MCPServer) added in 1.10.1. | |
| $required = @('Get-MCPProxyPath','Get-MCPOwner','Restart-MCPServer','Show-TextFiles') | |
| $missing = $required | Where-Object { -not (Get-Command $_ -Module PowerShell.MCP -ErrorAction SilentlyContinue) } | |
| if ($missing) { throw "Assembled module is missing expected commands: $($missing -join ', ')" } | |
| Write-Host "Command surface OK: $($required -join ', ')" | |
| # The bundled win-x64 proxy must resolve from inside the package. | |
| $proxy = Get-MCPProxyPath | |
| if (-not (Test-Path $proxy)) { throw "Get-MCPProxyPath returned a missing path: $proxy" } | |
| Write-Host "Proxy resolves: $proxy" | |
| Write-Host 'Assembled-module smoke test passed.' | |
| - name: Publish to PSGallery | |
| shell: pwsh | |
| env: | |
| PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} | |
| run: | | |
| Publish-Module -Path '${{ steps.stage.outputs.stage }}' -NuGetApiKey $env:PSGALLERY_API_KEY -Verbose | |
| - name: Create GitHub Release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: gh release create "${{ steps.ver.outputs.tag }}" --title "${{ steps.ver.outputs.tag }}" --notes-file "${{ steps.notes.outputs.notes_path }}" |