|
| 1 | +name: Release |
| 2 | + |
| 3 | +on: |
| 4 | + push: |
| 5 | + tags: |
| 6 | + - 'v*.*.*' |
| 7 | + |
| 8 | +jobs: |
| 9 | + release: |
| 10 | + runs-on: windows-latest |
| 11 | + permissions: |
| 12 | + contents: write |
| 13 | + steps: |
| 14 | + - uses: actions/checkout@v4 |
| 15 | + |
| 16 | + - name: Setup .NET |
| 17 | + uses: actions/setup-dotnet@v4 |
| 18 | + with: |
| 19 | + dotnet-version: | |
| 20 | + 8.0.x |
| 21 | + 9.0.x |
| 22 | +
|
| 23 | + - name: Derive version from tag |
| 24 | + id: ver |
| 25 | + shell: pwsh |
| 26 | + run: | |
| 27 | + $tag = "${env:GITHUB_REF_NAME}" |
| 28 | + $v = $tag.TrimStart('v') |
| 29 | + "version=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 30 | + "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 31 | +
|
| 32 | + - name: Verify version consistency across psd1 and csproj |
| 33 | + shell: pwsh |
| 34 | + run: | |
| 35 | + $expected = '${{ steps.ver.outputs.version }}' |
| 36 | + $expectedCsproj = "$expected.0" # csproj carries 4-part assembly version, psd1 carries 3-part |
| 37 | +
|
| 38 | + # psd1 |
| 39 | + $data = Import-PowerShellDataFile Staging/PowerShell.MCP.psd1 |
| 40 | + if ($data.ModuleVersion -ne $expected) { |
| 41 | + throw "Staging/PowerShell.MCP.psd1 ModuleVersion $($data.ModuleVersion) != tag v$expected" |
| 42 | + } |
| 43 | +
|
| 44 | + # PowerShell.MCP.csproj |
| 45 | + [xml]$mainProj = Get-Content 'PowerShell.MCP/PowerShell.MCP.csproj' |
| 46 | + $mainVer = $mainProj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 |
| 47 | + if ($mainVer -ne $expectedCsproj) { |
| 48 | + throw "PowerShell.MCP.csproj Version $mainVer != $expectedCsproj" |
| 49 | + } |
| 50 | +
|
| 51 | + # PowerShell.MCP.Proxy.csproj |
| 52 | + [xml]$proxyProj = Get-Content 'PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj' |
| 53 | + $proxyVer = $proxyProj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 |
| 54 | + if ($proxyVer -ne $expectedCsproj) { |
| 55 | + throw "PowerShell.MCP.Proxy.csproj Version $proxyVer != $expectedCsproj" |
| 56 | + } |
| 57 | +
|
| 58 | + Write-Host "All three version sources match: psd1=$expected, csproj=$expectedCsproj" |
| 59 | +
|
| 60 | + - name: Restore |
| 61 | + shell: pwsh |
| 62 | + run: | |
| 63 | + dotnet restore PowerShell.MCP/PowerShell.MCP.csproj --source https://api.nuget.org/v3/index.json --ignore-failed-sources |
| 64 | + dotnet restore PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj --source https://api.nuget.org/v3/index.json --ignore-failed-sources |
| 65 | +
|
| 66 | + - name: Build PowerShell.MCP.dll (net8.0) |
| 67 | + shell: pwsh |
| 68 | + run: | |
| 69 | + dotnet build PowerShell.MCP/PowerShell.MCP.csproj -c Release -f net8.0 --no-restore |
| 70 | +
|
| 71 | + - name: Publish PowerShell.MCP.Proxy for all RIDs |
| 72 | + shell: pwsh |
| 73 | + run: | |
| 74 | + foreach ($rid in 'win-x64','linux-x64','osx-x64','osx-arm64') { |
| 75 | + $outDir = "PowerShell.MCP.Proxy/bin/Release/net9.0/$rid/publish" |
| 76 | + Write-Host "=== Publishing Proxy for $rid ===" |
| 77 | + dotnet publish PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj ` |
| 78 | + -c Release -r $rid -o $outDir ` |
| 79 | + --self-contained --no-restore |
| 80 | + if ($LASTEXITCODE -ne 0) { throw "Proxy publish failed for $rid" } |
| 81 | + } |
| 82 | +
|
| 83 | + - name: Build help XML (PlatyPS v2) |
| 84 | + shell: pwsh |
| 85 | + run: | |
| 86 | + Install-Module Microsoft.PowerShell.PlatyPS -Scope CurrentUser -Force -AllowClobber |
| 87 | + Import-Module Microsoft.PowerShell.PlatyPS |
| 88 | + $mdFiles = Get-ChildItem 'PowerShell.MCP/PlatyPS/en-US/*.md' -Exclude 'PowerShell.MCP.md','*.md.bak' |
| 89 | + if ($mdFiles.Count -eq 0) { throw 'No PlatyPS markdown files found' } |
| 90 | + $helpObjects = $mdFiles | ForEach-Object { Import-MarkdownCommandHelp -Path $_.FullName } |
| 91 | + $helpOut = Join-Path $env:RUNNER_TEMP 'help-out' |
| 92 | + $null = Export-MamlCommandHelp -CommandHelp $helpObjects -OutputFolder $helpOut -Force |
| 93 | + $xml = Get-ChildItem $helpOut -Filter '*.xml' -Recurse |
| 94 | + if (-not $xml) { throw 'PlatyPS produced no XML output' } |
| 95 | + "helpOut=$helpOut" | Out-File -FilePath $env:GITHUB_ENV -Append |
| 96 | + Write-Host "Built $($xml.Count) help XML file(s)" |
| 97 | +
|
| 98 | + - name: Assemble module directory |
| 99 | + id: stage |
| 100 | + shell: pwsh |
| 101 | + run: | |
| 102 | + $stage = Join-Path $env:RUNNER_TEMP 'PowerShell.MCP' |
| 103 | + New-Item -ItemType Directory -Path $stage -Force | Out-Null |
| 104 | + New-Item -ItemType Directory -Path (Join-Path $stage 'en-US') -Force | Out-Null |
| 105 | + New-Item -ItemType Directory -Path (Join-Path $stage 'licenses') -Force | Out-Null |
| 106 | +
|
| 107 | + # DLL + Ude.NetStandard.dll (net8.0). The csproj's CopyUdeNetStandard target |
| 108 | + # places Ude.NetStandard.dll into the build output. |
| 109 | + $dllOut = 'PowerShell.MCP/bin/Release/net8.0' |
| 110 | + Copy-Item (Join-Path $dllOut 'PowerShell.MCP.dll') $stage |
| 111 | + Copy-Item (Join-Path $dllOut 'Ude.NetStandard.dll') $stage |
| 112 | +
|
| 113 | + # Manifest + psm1 |
| 114 | + Copy-Item 'Staging/PowerShell.MCP.psd1' $stage |
| 115 | + Copy-Item 'Staging/PowerShell.MCP.psm1' $stage |
| 116 | +
|
| 117 | + # Licenses / notices |
| 118 | + Copy-Item 'THIRD_PARTY_NOTICES.md' $stage |
| 119 | + Copy-Item 'licenses/*' (Join-Path $stage 'licenses') -Recurse |
| 120 | +
|
| 121 | + # Proxy binaries (multi-RID) |
| 122 | + foreach ($rid in 'win-x64','linux-x64','osx-x64','osx-arm64') { |
| 123 | + $ridDir = Join-Path $stage "bin/$rid" |
| 124 | + New-Item -ItemType Directory -Path $ridDir -Force | Out-Null |
| 125 | + $exeName = if ($rid -like 'win-*') { 'PowerShell.MCP.Proxy.exe' } else { 'PowerShell.MCP.Proxy' } |
| 126 | + Copy-Item "PowerShell.MCP.Proxy/bin/Release/net9.0/$rid/publish/$exeName" $ridDir |
| 127 | + } |
| 128 | +
|
| 129 | + # Help XML |
| 130 | + Get-ChildItem $env:helpOut -Filter '*.xml' -Recurse | Copy-Item -Destination (Join-Path $stage 'en-US') |
| 131 | +
|
| 132 | + "stage=$stage" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 133 | + Write-Host "=== Staged module contents ===" |
| 134 | + Get-ChildItem $stage -Recurse | Select-Object FullName |
| 135 | +
|
| 136 | + - name: Probe signing secret |
| 137 | + id: sign_probe |
| 138 | + shell: pwsh |
| 139 | + env: |
| 140 | + PFX_B64: ${{ secrets.CODE_SIGNING_PFX_BASE64 }} |
| 141 | + run: | |
| 142 | + $present = -not [string]::IsNullOrEmpty($env:PFX_B64) |
| 143 | + "present=$($present.ToString().ToLower())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 144 | +
|
| 145 | + - name: Sign Windows binaries (DLL + Proxy.exe only) |
| 146 | + if: steps.sign_probe.outputs.present == 'true' |
| 147 | + shell: pwsh |
| 148 | + env: |
| 149 | + PFX_B64: ${{ secrets.CODE_SIGNING_PFX_BASE64 }} |
| 150 | + PFX_PW: ${{ secrets.CODE_SIGNING_PFX_PASSWORD }} |
| 151 | + run: | |
| 152 | + $pfx = Join-Path $env:RUNNER_TEMP 'codesign.pfx' |
| 153 | + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:PFX_B64)) |
| 154 | + $pw = ConvertTo-SecureString $env:PFX_PW -AsPlainText -Force |
| 155 | + $cert = Get-PfxCertificate -FilePath $pfx -Password $pw |
| 156 | + $stage = '${{ steps.stage.outputs.stage }}' |
| 157 | +
|
| 158 | + # Sign ONLY the native Windows binaries. Script files (psd1 / psm1) are |
| 159 | + # left unsigned: Install-Module verifies Authenticode on script files and |
| 160 | + # would reject a self-signed signature on any machine where the cert is |
| 161 | + # not pre-trusted. Ude.NetStandard.dll is a third-party binary we do not |
| 162 | + # re-sign. Non-Windows Proxy binaries cannot carry Authenticode signatures. |
| 163 | + $files = @( |
| 164 | + "$stage/PowerShell.MCP.dll", |
| 165 | + "$stage/bin/win-x64/PowerShell.MCP.Proxy.exe" |
| 166 | + ) |
| 167 | + Set-AuthenticodeSignature -FilePath $files -Certificate $cert ` |
| 168 | + -HashAlgorithm SHA256 ` |
| 169 | + -TimestampServer http://timestamp.digicert.com ` |
| 170 | + -IncludeChain NotRoot |
| 171 | +
|
| 172 | + # Self-signed certs yield Status=UnknownError on machines where the cert |
| 173 | + # is not in the trust store (GitHub runners don't trust it). That's |
| 174 | + # expected — the signature itself is correct, only chain validation needs |
| 175 | + # the cert installed on the end-user machine. Verify thumbprint matches + |
| 176 | + # reject tamper / missing. |
| 177 | + $expected = $cert.Thumbprint |
| 178 | + $bad = Get-AuthenticodeSignature $files | Where-Object { |
| 179 | + $_.SignerCertificate.Thumbprint -ne $expected -or |
| 180 | + $_.Status -notin @('Valid','UnknownError') |
| 181 | + } |
| 182 | + if ($bad) { |
| 183 | + $bad | Format-Table Status, StatusMessage, Path |
| 184 | + throw 'One or more Windows binary signatures are missing, tampered, or signed by the wrong cert.' |
| 185 | + } |
| 186 | +
|
| 187 | + - name: Guard — Windows binaries must be signed for a tagged release |
| 188 | + if: steps.sign_probe.outputs.present != 'true' |
| 189 | + shell: pwsh |
| 190 | + run: | |
| 191 | + throw 'CODE_SIGNING_PFX_BASE64 is not configured. Tagged releases must be signed. Add the secret and re-run, or delete the tag.' |
| 192 | +
|
| 193 | + - name: Assert signing distribution (only DLL + win-x64 Proxy.exe) |
| 194 | + shell: pwsh |
| 195 | + run: | |
| 196 | + $stage = '${{ steps.stage.outputs.stage }}' |
| 197 | + $shouldBeSigned = @( |
| 198 | + "$stage/PowerShell.MCP.dll", |
| 199 | + "$stage/bin/win-x64/PowerShell.MCP.Proxy.exe" |
| 200 | + ) |
| 201 | + $shouldBeUnsigned = @( |
| 202 | + "$stage/PowerShell.MCP.psd1", |
| 203 | + "$stage/PowerShell.MCP.psm1", |
| 204 | + "$stage/Ude.NetStandard.dll" |
| 205 | + ) |
| 206 | + foreach ($f in $shouldBeSigned) { |
| 207 | + $s = Get-AuthenticodeSignature $f |
| 208 | + if ($s.Status -eq 'NotSigned') { throw "Expected signed but NotSigned: $f" } |
| 209 | + } |
| 210 | + foreach ($f in $shouldBeUnsigned) { |
| 211 | + $s = Get-AuthenticodeSignature $f |
| 212 | + if ($s.Status -ne 'NotSigned') { throw "Expected unsigned but $($s.Status): $f" } |
| 213 | + } |
| 214 | + Write-Host 'Signing distribution OK: DLL + win-x64 Proxy.exe signed; script files + Ude unsigned.' |
| 215 | +
|
| 216 | + - name: Publish to PSGallery |
| 217 | + shell: pwsh |
| 218 | + env: |
| 219 | + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} |
| 220 | + run: | |
| 221 | + Publish-Module -Path '${{ steps.stage.outputs.stage }}' -NuGetApiKey $env:PSGALLERY_API_KEY -Verbose |
| 222 | +
|
| 223 | + - name: Extract release notes for this version |
| 224 | + id: notes |
| 225 | + shell: pwsh |
| 226 | + run: | |
| 227 | + $version = '${{ steps.ver.outputs.version }}' |
| 228 | + $lines = Get-Content Staging/ReleaseNotes.md |
| 229 | + $inSection = $false |
| 230 | + $section = New-Object System.Collections.Generic.List[string] |
| 231 | + foreach ($line in $lines) { |
| 232 | + if ($line -match '^# Version:\s*(\S+)') { |
| 233 | + if ($matches[1] -eq $version) { $inSection = $true; continue } |
| 234 | + elseif ($inSection) { break } |
| 235 | + } |
| 236 | + if ($inSection) { $section.Add($line) } |
| 237 | + } |
| 238 | + $body = ($section -join "`n").Trim() |
| 239 | + if ([string]::IsNullOrWhiteSpace($body)) { |
| 240 | + throw "No release notes section found for version $version in Staging/ReleaseNotes.md" |
| 241 | + } |
| 242 | + $notesPath = Join-Path $env:RUNNER_TEMP 'release-notes.md' |
| 243 | + Set-Content -Path $notesPath -Value $body -Encoding UTF8 |
| 244 | + "notes_path=$notesPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 245 | +
|
| 246 | + - name: Create GitHub Release |
| 247 | + env: |
| 248 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 249 | + run: gh release create "${{ steps.ver.outputs.tag }}" --title "${{ steps.ver.outputs.tag }}" --notes-file "${{ steps.notes.outputs.notes_path }}" |
0 commit comments