Skip to content

Commit e8596f7

Browse files
committed
ci: add tag-triggered release workflow with signing guards
1 parent a648819 commit e8596f7

2 files changed

Lines changed: 274 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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 }}"

Staging/ReleaseNotes.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Version: 1.7.7
2+
## New Features
3+
- Authenticode-signed Windows binaries (`PowerShell.MCP.dll`, `bin/win-x64/PowerShell.MCP.Proxy.exe`) so the module is accepted on WDAC / Device Guard machines with a one-time trust of the signer. Closes #46.
4+
5+
## Improvements
6+
- Bundle LGPL-2.1 license texts for Ude.NetStandard (compliance obligation).
7+
- Build-AllPlatforms.ps1 gains `-Sign` switch with interactive PFX passphrase for local release builds.
8+
9+
## Bug Fixes
10+
- Status line truncation was broken for pipelines beginning with leading whitespace.
11+
12+
---
13+
14+
<!--
15+
Release notes format:
16+
# Version: X.Y.Z
17+
## New Features / Improvements / Bug Fixes / Internal
18+
- Bullet point
19+
20+
The release.yml workflow extracts the section matching the pushed tag version
21+
(v1.7.8 → finds "# Version: 1.7.8"). Releasing a tag without a matching section
22+
here will fail the workflow on purpose.
23+
24+
Add new version sections at the TOP of the file. Keep older sections for history.
25+
-->

0 commit comments

Comments
 (0)