This guide assumes you have a working binary module from the first compiled-module post, a code-signing certificate (or the ability to issue one from your internal CA), and either a private NuGet feed or a Gitlab Package Registry set up as a Powershell repository.
A binary module that lives only on your laptop is a script. A binary module that ships through CI, signed, versioned, and installable with Install-Module is a product. This post walks through the second.
What "Signed" Actually Means Here
There are three independent signatures involved:
- Strong name assembly identity, prevents accidental version conflicts. Optional in .NET 10. Skip unless you have a specific reason.
- Authenticode signature on the
.dllproves the binary came from you, letsAllSignedexecution policy load it. - Authenticode signature on the
.psd1,.ps1xml, and any.ps1same proof, for the script files. This is the one most teams forget. A signed DLL with an unsigned manifest failsAllSigned.
If your environment runs Powershell with
Set-ExecutionPolicy AllSigned(which it should, on production hosts), every file that participates in the module load needs a valid signature, not just the assembly.
The Build Pipeline at 30,000 Feet
checkout -> restore -> build -> test -> sign DLL -> stage module folder -> sign manifest+ps1xml -> nuspec -> nuget pack -> nuget push
Each step is a single command. The interesting parts are signing and packaging the rest is dotnet.
Versioning
One source of truth. Pick the build number at CI time, then flow it everywhere.
Directory.Build.props (next to your .csproj):
<Project>
<PropertyGroup>
<Version Condition=" '$(Version)' == '' ">0.0.0-local</Version>
<AssemblyVersion>$(Version.Split('-')[0]).0</AssemblyVersion>
<FileVersion>$(Version.Split('-')[0]).0</FileVersion>
<InformationalVersion>$(Version)</InformationalVersion>
</PropertyGroup>
</Project>
In CI:
dotnet build -c Release /p:Version=1.4.7
Or, with semantic-release-style tags:
VERSION="${CI_COMMIT_TAG#v}" # e.g. tag v1.4.7 -> 1.4.7
dotnet build -c Release /p:Version=$VERSION
The manifest needs the same version. Patch it during the staging step (next section) instead of editing MyOps.psd1 by hand.
Stage the Module Folder
Install-Module expects a folder structure that matches the manifest. Build to a temp directory:
$ver = $env:VERSION
$staging = Join-Path $PSScriptRoot "out/MyOps/$ver"
$bin = "src/bin/Release/net10.0"
$null = New-Item -ItemType Directory -Path $staging -Force
# Copy compiled assembly + dependencies
Copy-Item "$bin/MyOps.dll" $staging
Copy-Item "$bin/MyOps.deps.json" $staging
Copy-Item "$bin/*.dll" $staging -Exclude 'System.Management.Automation.dll'
# Patch the manifest with the build version
$psd1 = Get-Content "src/MyOps.psd1" -Raw
$psd1 = $psd1 -replace "ModuleVersion\s*=\s*'[^']+'", "ModuleVersion = '$ver'"
Set-Content -Path (Join-Path $staging 'MyOps.psd1') -Value $psd1 -Encoding UTF8
# Copy formats and types if you have them
Copy-Item "src/MyOps.format.ps1xml" $staging -ErrorAction SilentlyContinue
Copy-Item "src/MyOps.types.ps1xml" $staging -ErrorAction SilentlyContinue
Test-ModuleManifest (Join-Path $staging 'MyOps.psd1')
Test-ModuleManifest is the safety net: it parses the .psd1, validates required fields, and checks that referenced files exist. CI should fail loudly here if anything's off.
Don't ship
System.Management.Automation.dllwith your module. It's the host. Shipping your own copy will load a parallel SMA in some scenarios and break in spectacular ways.
Signing the DLL
Two paths: signtool (Windows-only, classic) or dotnet sign / AzureSignTool (cross-platform, integrates with HSMs and KeyVault).
signtool (Windows)
$timestampUrl = 'http://timestamp.digicert.com'
$cert = 'cert:\CurrentUser\My\<thumbprint>'
$signArgs = @(
'sign'
'/sha1', '<thumbprint>'
'/tr', $timestampUrl
'/td', 'sha256'
'/fd', 'sha256'
"$staging\MyOps.dll"
)
& signtool @signArgs
AzureSignTool (cross-platform, KeyVault-backed)
dotnet tool install --global AzureSignTool
AzureSignTool sign \
--azure-key-vault-url "https://kv-codesigning.vault.azure.net" \
--azure-key-vault-client-id "$AZ_CLIENT_ID" \
--azure-key-vault-tenant-id "$AZ_TENANT_ID" \
--azure-key-vault-client-secret "$AZ_CLIENT_SECRET" \
--azure-key-vault-certificate "codesign-2025" \
--timestamp-rfc3161 http://timestamp.digicert.com \
--timestamp-digest sha256 \
--file-digest sha256 \
"$STAGING/MyOps.dll"
Always use a timestamp server. Without one, your signature expires the day your cert expires not the day the cert was valid. With timestamping, modules stay verifiable indefinitely.
Signing the Manifest and .ps1xml
This is the step everyone forgets. Use Set-AuthenticodeSignature:
$cert = Get-ChildItem cert:\CurrentUser\My\<thumbprint>
foreach ($f in @('MyOps.psd1','MyOps.format.ps1xml','MyOps.types.ps1xml'))
{
$path = Join-Path $staging $f
if (Test-Path $path)
{
$signParams = @{
FilePath = $path
Certificate = $cert
TimestampServer = 'http://timestamp.digicert.com'
HashAlgorithm = 'SHA256'
}
$null = Set-AuthenticodeSignature @signParams
}
}
Verify everything is signed before packaging:
Get-ChildItem $staging -Recurse |
Where-Object { $_.Extension -in '.dll','.psd1','.ps1xml','.ps1' } |
ForEach-Object {
$sig = Get-AuthenticodeSignature $_.FullName
if ($sig.Status -ne 'Valid')
{
throw "Unsigned or invalid: $($_.FullName) ($($sig.Status))"
}
}
This loop is the single most valuable two minutes of CI time you can add. It catches forgetting-to-sign before users do.
Packaging .nupkg
Publish-PSResource will create the nupkg for you, but for full CI control build it yourself with a .nuspec:
MyOps.nuspec:
<?xml version="1.0"?>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
<metadata>
<id>MyOps</id>
<version>$version$</version>
<authors>Ricardo Martin</authors>
<description>Operational helpers for MyOps.</description>
<projectUrl>https://github.com/rcfmartin/myops</projectUrl>
<licenseUrl>https://example.com/license</licenseUrl>
<tags>PSModule PSEdition_Core ops</tags>
</metadata>
<files>
<file src="out\MyOps\$version$\**\*" target="" />
</files>
</package>
Pack:
nuget pack MyOps.nuspec -Version "$VERSION" -OutputDirectory dist
The PSModule and PSEdition_Core tags matter: PSResourceGet uses them to recognize the package as a Powershell module. Forget them and Find-PSResource returns nothing.
Pushing to a Feed
To a Gitlab Package Registry
The repository setup is in the Gitlab Powershell repository post. Once the repository is registered:
$publishParams = @{
Path = "out/MyOps/$ver"
Repository = 'gitlab'
ApiKey = $env:GITLAB_TOKEN
}
Publish-PSResource @publishParams
Or, with the raw nupkg:
curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
--upload-file "dist/MyOps.$VERSION.nupkg" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages/nuget/"
To a Private NuGet Feed
dotnet nuget push "dist/MyOps.$VERSION.nupkg" \
--source "$NUGET_SOURCE" \
--api-key "$NUGET_API_KEY"
A Full GitLab CI Pipeline
stages: [build, test, sign, publish]
variables:
VERSION: "0.0.${CI_PIPELINE_IID}"
build:
stage: build
image: mcr.microsoft.com/dotnet/sdk:10.0
script:
- dotnet restore src
- dotnet build src -c Release /p:Version=$VERSION
artifacts:
paths: [src/bin/Release/net10.0/]
test:
stage: test
image: mcr.microsoft.com/dotnet/sdk:10.0
script:
- dotnet test tests -c Release --no-build --logger "junit;LogFilePath=test-results.xml"
artifacts:
when: always
reports: { junit: test-results.xml }
sign:
stage: sign
image: mcr.microsoft.com/powershell:lts-10.0
before_script:
- dotnet tool install --global AzureSignTool
- export PATH="$PATH:/root/.dotnet/tools"
script:
- pwsh -File ./build/Stage-Module.ps1 -Version $VERSION
- |
AzureSignTool sign \
--azure-key-vault-url $KV_URL \
--azure-key-vault-client-id $AZ_CLIENT_ID \
--azure-key-vault-tenant-id $AZ_TENANT_ID \
--azure-key-vault-client-secret $AZ_CLIENT_SECRET \
--azure-key-vault-certificate codesign-2025 \
--timestamp-rfc3161 http://timestamp.digicert.com \
--timestamp-digest sha256 --file-digest sha256 \
"out/MyOps/$VERSION/MyOps.dll"
- pwsh -File ./build/Sign-Manifest.ps1 -Path "out/MyOps/$VERSION"
- pwsh -File ./build/Verify-Signatures.ps1 -Path "out/MyOps/$VERSION"
artifacts:
paths: [out/]
publish:
stage: publish
image: mcr.microsoft.com/powershell:lts-10.0
rules:
- if: $CI_COMMIT_TAG
script:
- pwsh -c "Register-PSResourceRepository -Name gitlab -Uri '$REPO_URL' -Trusted"
- pwsh -c "Publish-PSResource -Path 'out/MyOps/$VERSION' -Repository gitlab -ApiKey '$GITLAB_TOKEN'"
Three things worth noticing:
- The
signjob pulls the cert from KeyVault no key material on the runner. - The
publishjob only runs on tag pushes. Branch pipelines build and test but never publish. - Every job is reproducible by running the same
pwshscript locally with the right env vars. CI is just the runner.
Consumer Side Locked Down
On a production host running AllSigned:
Set-ExecutionPolicy -ExecutionPolicy AllSigned -Scope LocalMachine
Register-PSResourceRepository -Name corp -Uri $REPO_URL -Trusted
Install-PSResource -Name MyOps -Repository corp -Version '[1.4.7]'
Pin the version on production. Never Install-PSResource MyOps -Repository corp without -Version in a script you'll have a different version installed depending on when the script ran. PSResourceGet uses NuGet range syntax, so [1.4.7] means "exactly 1.4.7".
Operational Notes
- Trust the cert chain on every host. Internal CA roots need to be in
Trusted Root Certification Authoritiesand your code-signing intermediate inIntermediate Certification Authorities. Without that, signed modules fail to load with no obvious error. - Build a "nightly" channel, separate feed, separate cert if you can. Don't pollute the production feed with development versions.
- Don't auto-update modules in production. The benefit is rarely worth the risk; pinned versions plus a deliberate upgrade flow is the right tradeoff.
- Keep the unsigned source out of the package. The nupkg should contain only what's needed at runtime.
.csfiles in the package are a code-leak surface and add nothing. - Test the install on a clean host. "Works on the build agent" is the default state of a broken package. A short Pester test that does
Install-PSResourceinto a tempPSModulePathand runs one cmdlet is the difference between "ships" and "rolls back".
What to Do Next
A signed binary module behind a real CI pipeline isn't more complex than the alternatives it's just disciplined. Pick a version, build, test, sign every artifact, verify the signatures before you ship, push only on tags, and pin versions on the consumer side. Once the pipeline exists, releasing a new version is a git tag and nothing else.
Three concrete moves to wire this up for one module this week:
- Make
git tag v1.2.3the only release trigger. No "Run workflow" button, no manual version bumps in the YAML. If a release isn't a tag, half your team will skip a step half the time and ship unsigned bits. - Sign every artifact, not just the assembly. That means the DLL, the manifest catalog (
.cat), the.nupkg, and any embedded native libraries. Verify withGet-AuthenticodeSignatureagainst a clean machine before pushing to the feed. - Pin the module version on consumers.
Install-PSResource -Name MyModule -Version 1.2.3in your bootstrap, never-Version *. Auto-updating production from a private feed is how a typo inmasterrolls out to a fleet at 2am.
Pairs naturally with the C# testing post (because the only safe way to ship signed binaries is to trust the test gate) and the secret management post (because the signing certificate is exactly the kind of secret that absolutely cannot live in the repo).


