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 .dll proves the binary came from you, lets AllSigned execution policy load it.
  • Authenticode signature on the .psd1, .ps1xml, and any .ps1 same proof, for the script files. This is the one most teams forget. A signed DLL with an unsigned manifest fails AllSigned.

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.dll with 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 sign job pulls the cert from KeyVault no key material on the runner.
  • The publish job only runs on tag pushes. Branch pipelines build and test but never publish.
  • Every job is reproducible by running the same pwsh script 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 Authorities and your code-signing intermediate in Intermediate 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. .cs files 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-PSResource into a temp PSModulePath and 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:

  1. Make git tag v1.2.3 the 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.
  2. Sign every artifact, not just the assembly. That means the DLL, the manifest catalog (.cat), the .nupkg, and any embedded native libraries. Verify with Get-AuthenticodeSignature against a clean machine before pushing to the feed.
  3. Pin the module version on consumers. Install-PSResource -Name MyModule -Version 1.2.3 in your bootstrap, never -Version *. Auto-updating production from a private feed is how a typo in master rolls 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).