This document describes how to cut a release of PowerShell.MCP and publish it to the PowerShell Gallery.
Audience: repo maintainers with PSGallery co-owner rights. Secrets (API keys, signing certificate password, vault paths) are never stored in this repo. They live only in GitHub Actions repo secrets and in the maintainer's local environment.
A release of PowerShell.MCP consists of:
- Version bump across three files (manifest + two
.csproj) - New section added to
CHANGELOG.md - Pre-flight local build / test
- Commit + push to
main - Tag
vX.Y.Z+ push tag → GitHub Actions handles everything downstream
The GitHub Actions workflow at .github/workflows/release.yml handles: version-consistency + syntax/IEX checks, CHANGELOG-section fail-fast, the full unit suite (net8.0 + net9.0), multi-RID build, PlatyPS help XML generation, Authenticode signing (DLL + win-x64 Proxy.exe only), signing-distribution assertion, an Import-Module smoke test of the assembled artifact, PSGallery publish, and GitHub Release creation from CHANGELOG.md.
PSGallery publishes cannot be deleted or replaced — only unlisted. The workflow therefore re-proves the release on the runner instead of trusting the maintainer's local run; every gate below runs before Publish to PSGallery, and any failure aborts the release with nothing published:
- Version consistency — psd1 ModuleVersion and both
.csproj<Version>must equal the tag. - PowerShell syntax + AMSI #50 IEX guard — first-party
.ps1/.psm1parse; the polling engine stays free ofInvoke-Expression. - CHANGELOG section (fail fast) — release notes for the tagged version must exist (extracted up front, so a forgotten section fails in seconds — not after publish).
- Unit suite (net8.0 + net9.0) —
dotnet teston the exact tagged commit; CI never relies on the localRun-AllTests.ps1having been run. - Signing distribution — the right files signed, script files left unsigned.
- Assembled-module smoke test — the staged, signed package is imported and must report the tagged version, expose its command surface (incl.
Restart-MCPServer), and resolve its bundled proxy.
Manual fallback is documented at the bottom but should rarely be needed.
Each maintainer who can publish needs:
- GitHub permissions: Write or Admin on this repo
- PSGallery account listed as a co-owner of the
PowerShell.MCPpackage- Request via the Manage Owners page
- Each owner generates their own API key at PSGallery API Keys
- Code signing certificate (yotsuda's
yotsuda.pfx) — required, not optional- Rationale: WDAC / Device Guard environments block unsigned native binaries. See issue #46.
- The PFX and its password are distributed out-of-band and stored as GitHub Actions secrets
- Local toolchain:
- PowerShell 7.4+
- .NET SDK 8.0 and 9.0 (both — main DLL is net8.0, Proxy is net9.0)
- Git
ghCLI (for tag push and release verification)
PowerShell.MCP follows Semantic Versioning:
- MAJOR — breaking changes to cmdlet parameters, MCP tool contracts, or proxy protocol
- MINOR — new cmdlets, new MCP tools, new parameters (backward-compatible)
- PATCH — bug fixes, documentation, performance
The current version lives in three files that must be kept in sync. The release workflow's Verify version consistency step fails fast if they disagree.
| File | Format | Example |
|---|---|---|
Staging/PowerShell.MCP.psd1 |
3-part | ModuleVersion = '1.7.8' |
PowerShell.MCP/PowerShell.MCP.csproj |
4-part | <Version>1.7.8.0</Version> |
PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj |
4-part | <Version>1.7.8.0</Version> |
Edit all three files to the same logical version (1.7.8 / 1.7.8.0):
# Manual edits to:
# Staging/PowerShell.MCP.psd1 — ModuleVersion = '1.7.8'
# PowerShell.MCP/PowerShell.MCP.csproj — <Version>1.7.8.0</Version>
# PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj — <Version>1.7.8.0</Version>Prepend a new # Version: X.Y.Z section to CHANGELOG.md:
# Version: 1.7.8
## New Features
- ...
## Improvements
- ...
## Bug Fixes
- ...
# Version: 1.7.7
... (previous sections kept for history) ...The workflow extracts the section matching the pushed tag; tagging without a matching section fails the workflow before publish (this is intentional).
Verify the module builds cleanly and loads on the dev machine:
.\Build-AllPlatforms.ps1 # Builds all 4 RIDs, writes to installed module path
Import-Module PowerShell.MCP -Force
Get-Module PowerShell.MCP # Should show the new version
Get-Command -Module PowerShell.MCP # All expected cmdlets presentRun the test suite:
.\Tests\Run-AllTests.ps1Optional (but recommended for signed releases): verify local signing still works end-to-end:
.\Build-AllPlatforms.ps1 -Sign # Prompts once for PFX passwordThe -Sign path is only needed to test the local signing flow. The actual signing used by the release happens in CI from the GHA secrets — the local signed copy is never published.
git add Staging/PowerShell.MCP.psd1 `
PowerShell.MCP/PowerShell.MCP.csproj `
PowerShell.MCP.Proxy/PowerShell.MCP.Proxy.csproj `
CHANGELOG.md
git commit -m "Bump version to 1.7.8"
git push origin main(Use separate commits for actual code changes that comprise the release; the version-bump + release-notes commit goes last.)
git tag v1.7.8
git push origin v1.7.8That tag push starts release.yml, which:
- Verifies all 3 version sources match
v1.7.8 - Syntax-checks the PowerShell sources and enforces the AMSI #50 IEX guard
- Extracts the matching
CHANGELOG.mdsection up front — a missing section fails here, before any build or publish - Runs the full unit suite (
dotnet test, net8.0 + net9.0) — a red test aborts the release - Builds DLL (net8.0) + Proxy for all 4 RIDs (
win-x64,linux-x64,osx-x64,osx-arm64, self-contained) - Generates Get-Help XML via PlatyPS
- Assembles the module directory (DLL, psd1, psm1, Ude.NetStandard.dll, bin/*/Proxy, en-US/*.xml, licenses/*, THIRD_PARTY_NOTICES.md)
- Signs exactly two files:
PowerShell.MCP.dll+bin/win-x64/PowerShell.MCP.Proxy.exewith timestamp from DigiCert - Asserts the signing distribution is correct (signed files signed, unsigned files unsigned) — fails publish if not
- Smoke-tests the assembled package: imports it, checks the version equals the tag, the command surface is present, and the bundled proxy resolves — the last gate before publish
- Publishes to PSGallery
- Creates a GitHub Release with the
CHANGELOG.mdbody extracted in step 3
Total runtime: ~4–6 minutes.
# PSGallery side
Find-Module PowerShell.MCP -RequiredVersion 1.7.8
# Signature distribution (Save-Module pulls the published copy — does NOT install)
$tmp = Join-Path $env:TEMP "verify-$(Get-Random)"
Save-Module PowerShell.MCP -RequiredVersion 1.7.8 -Path $tmp
$base = Join-Path $tmp 'PowerShell.MCP\1.7.8'
Get-ChildItem $base -Recurse -File |
Where-Object { $_.Name -match '\.(dll|exe|psd1|psm1)$' } |
ForEach-Object {
$s = Get-AuthenticodeSignature $_.FullName
[pscustomobject]@{
File = $_.Name
Status = $s.Status
Signer = $s.SignerCertificate.Thumbprint
Timestamp = [bool]$s.TimeStamperCertificate
}
} | Format-Table -AutoSizeExpected:
PowerShell.MCP.dll—Valid, timestampedbin/win-x64/PowerShell.MCP.Proxy.exe—Valid, timestampedpsd1/psm1/Ude.NetStandard.dll—NotSigned- Non-Windows Proxy binaries —
UnknownError(Authenticode is Windows-only; this is expected and not a bug)
These are load-bearing rules. The release workflow's Assert signing distribution step enforces them; violating them fails the release.
Sign:
PowerShell.MCP.dllbin/win-x64/PowerShell.MCP.Proxy.exe
Do NOT sign:
PowerShell.MCP.psd1—Install-Moduleverifies Authenticode signatures on script files and rejects self-signed ones on any machine where the cert is not pre-trusted. Signing the psd1 with a self-signed cert bricksInstall-Modulefor most users. (Learned the hard way on UiPathOrch 0.9.16.5.)PowerShell.MCP.psm1— same reasonUde.NetStandard.dll— third-party binary, we do not re-sign vendor codebin/linux-x64/PowerShell.MCP.Proxy,bin/osx-x64/PowerShell.MCP.Proxy,bin/osx-arm64/PowerShell.MCP.Proxy— Authenticode is a Windows-only signature format; ELF and Mach-O binaries cannot carry it
PowerShell.MCP uses a self-signed code-signing certificate (yotsuda.pfx). The public key is published at https://github.com/yotsuda/code-signing. Users in WDAC environments add it as a trusted publisher once, after which every future release passes policy without per-version hash exceptions. A commercial CA cert would remove the one-time trust step but costs hundreds of USD/year; the tradeoff is documented in issue #46.
All signatures include a timestamp from http://timestamp.digicert.com. This means signatures remain verifiable after the signing cert itself expires, because the timestamp proves the signature existed while the cert was still valid.
The release.yml workflow requires all three of these. They are set via gh secret set.
| Secret | Purpose |
|---|---|
PSGALLERY_API_KEY |
API key with Push scope for the PowerShell.MCP package |
CODE_SIGNING_PFX_BASE64 |
Base64-encoded yotsuda.pfx |
CODE_SIGNING_PFX_PASSWORD |
Password for yotsuda.pfx |
Setup commands (run once per fresh repo / rotation):
# PFX base64 (no password prompt). Point $env:POWERSHELLMCP_PFX_PATH at your
# local signing cert — its storage location is intentionally kept out of this repo.
$b64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($env:POWERSHELLMCP_PFX_PATH))
gh secret set CODE_SIGNING_PFX_BASE64 --repo yotsuda/PowerShell.MCP --body $b64
# PFX password (interactive, hidden input from gh)
gh secret set CODE_SIGNING_PFX_PASSWORD --repo yotsuda/PowerShell.MCP
# PSGallery API key (interactive, hidden input from gh)
gh secret set PSGALLERY_API_KEY --repo yotsuda/PowerShell.MCPIf any secret is missing, the workflow's Guard — Windows binaries must be signed for a tagged release step throws before publishing.
The CI gates above are automated — they prove correctness but still publish the instant a v* tag is pushed. As the install base grows, a recommended extra gate is a manual approval so a person confirms intent right before the irreversible PSGallery push. This is a one-time repo-settings change (no workflow rewrite needed):
- Settings → Environments → New environment, name it
release. - Add yourself (and any co-maintainers) under Required reviewers.
- Move the three release secrets (
PSGALLERY_API_KEY,CODE_SIGNING_PFX_BASE64,CODE_SIGNING_PFX_PASSWORD) from repo secrets into thereleaseenvironment's secrets. - In
release.yml, addenvironment: releaseto thereleasejob.
With this, every tag push runs all the validation gates, then pauses for a reviewer to approve before the signing/publish steps can read the environment secrets. Approve from the Actions run page; reject to abort with nothing published. Until this is configured, releases publish automatically once the tag's gates go green.
PSGallery does not allow deleting or overwriting a published version. If a release is broken:
- Publish a patch version (
X.Y.Z+1) with the fix via the normal release procedure - In the PSGallery UI, unlist the broken version (Manage Package → Unlist)
- Unlisted versions remain installable by exact version but are hidden from
Find-Module
- Unlisted versions remain installable by exact version but are hidden from
- Update
CHANGELOG.mdto note the pulled version
Never force-push a release tag — create a new tag instead.
Use only if the GHA workflow is broken and a release cannot wait.
# Build + sign locally (writes to installed module path by default)
.\Build-AllPlatforms.ps1 -Sign
$ver = '1.7.8'
$modulePath = 'C:\Program Files\PowerShell\7\Modules\PowerShell.MCP'
# Stage a CLEAN copy to publish. The installed path accumulates build
# leftovers (*.bak, *.stash-*, *.new-*) that Publish-Module would otherwise
# package into the .nupkg. The GHA path doesn't hit this because it assembles
# the module from scratch in a fresh RUNNER_TEMP dir.
$stage = Join-Path $env:TEMP 'PowerShell.MCP-publish'
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item $modulePath $stage -Recurse
Get-ChildItem $stage -Recurse -File |
Where-Object { $_.Name -like '*.bak' -or $_.Name -like '*.stash-*' -or $_.Name -like '*.new-*' } |
Remove-Item -Force
# Verify signatures on the staged copy
Get-ChildItem $stage -Recurse -File |
Where-Object { $_.Name -in @('PowerShell.MCP.dll','PowerShell.MCP.Proxy.exe') } |
Get-AuthenticodeSignature | Format-Table Status, Path
# Publish — use your own PSGallery API key
Publish-Module -Path $stage -NuGetApiKey <your-api-key>
# Create the GitHub Release with ONLY this version's section (mirrors release.yml's
# extraction — do NOT pass the whole CHANGELOG.md, that dumps every past version).
$inSection = $false
$section = foreach ($line in Get-Content CHANGELOG.md) {
if ($line -match '^# Version:\s*(\S+)') {
if ($matches[1] -eq $ver) { $inSection = $true; continue }
elseif ($inSection) { break }
}
if ($inSection) { $line }
}
$notes = ($section -join "`n").Trim()
if (-not $notes) { throw "No CHANGELOG.md section found for $ver" }
$notesFile = Join-Path $env:TEMP "release-notes-$ver.md"
Set-Content -Path $notesFile -Value $notes -Encoding UTF8
gh release create "v$ver" --title "v$ver" --notes-file $notesFilePrefer fixing the workflow over using this path — manual publishes bypass the signing-distribution assertion and are easier to get wrong (e.g., leaving signed script files that would break Install-Module on user machines).
- Version bumped in all 3 files (psd1 / 2 csproj) to matching value
-
CHANGELOG.mdhas a new# Version: X.Y.Zsection at the top -
.\Build-AllPlatforms.ps1clean -
.\Tests\Run-AllTests.ps1green - Bump commit pushed to
main - Tag
vX.Y.Zpushed - GHA
Releaseworkflow succeeded -
Find-Module PowerShell.MCP -RequiredVersion X.Y.Zreturns the new version -
Save-Moduleverification showsPowerShell.MCP.dll+win-x64/Proxy.exesigned, nothing else - GitHub Release page shows the notes extracted from
CHANGELOG.md