PowerShell Modules

June 10, 2026 · View on GitHub

Scope: Sprint-0006/0007. How a PowerShell module's version (the ModuleVersion and Prerelease fields in .psd1) is computed from NBGV and how the prerelease label maps onto the 5-tier promotion model.

Audience: Anyone who wonders why Update-ModuleManifest rejected their prerelease string, anyone running nbgv get-version, anyone promoting a module from one feed tier to the next.

Status: Authoritative for sprint-0006/0007. Mirrors the structure of CSharp-Packages-Versioning.md but documents the PowerShell-specific translation step.

Strategy update (sprint-0007 — Immutable Build). A module's version (ModuleVersion + Prerelease) is computed once at the moment of the Experimental build and stays the same as the module promotes through the five PowerShellGet feeds. Promotion does not bump {height}, does not re-evaluate version.json, and does not re-stamp the .psd1. The prerelease label declares the ceiling tier; the current tier is which feed and BuildMaster stage the module currently lives in. The "promotion procedure" in §7 remains the right tool for cutting a new candidate at the next tier (Sprint→Alpha) — but moving an existing .nupkg between feeds is now a Promote-ProGetPackage call, not a label edit + rebuild. See Immutable-Build-Strategy.md §6.

Not in this doc:


1. The two-format gap

NBGV emits NuGet-style version strings. PowerShell Gallery / PSResource require a stricter format. The translation is the entire job of Get-PSModuleVersionFromNBGV.

LayerExampleAllowed shape
NBGV NuGetPackageVersion0.1.0-Sprint.42SemVer 2.0 with . separators in the prerelease segment
Update-ModuleManifest -PrereleaseSprint042Alphanumeric only — no ., no -, no underscores
Update-ModuleManifest -ModuleVersion[Version]'0.1.0'2-, 3-, or 4-part System.Version; no prerelease here

The two .psd1 fields together reconstruct the SemVer string when published: the gallery joins them as <ModuleVersion>-<Prerelease> (e.g. 0.1.0-Sprint042).


2. The translation cmdlet

File: src/ATAP.Utilities.BuildTooling.PowerShell/public/Get-PSModuleVersionFromNBGV.ps1

Inputs: -ModuleRoot (absolute path to the module folder).

Outputs: [PSCustomObject] with three fields

  • ModuleVersion[System.Version] (3-part, e.g. 0.1.0).
  • Prerelease — alphanumeric string (e.g. Sprint042) or empty.
  • FullNuGetVersion — raw NBGV output (e.g. 0.1.0-Sprint.42).

Algorithm:

  1. Validate ModuleRoot exists.
  2. Confirm nbgv CLI is on PATH (else throw with install hint).
  3. Push-Location $ModuleRoot and run nbgv get-version --variable NuGetPackageVersion. Capture stdout/stderr.
  4. Throw if the exit code is non-zero or stdout is empty.
  5. Parse with the regex ^(?<Major>\d+)\.(?<Minor>\d+)\.(?<Patch>\d+)(?:-(?<Label>[A-Za-z][A-Za-z0-9]*)(?:\.(?<Height>\d+))?(?:\.g[0-9a-f]+)?)?$.
  6. Build the [Version] from Major.Minor.Patch.
  7. If Label is empty → stable / Production tier → Prerelease = ''.
  8. Otherwise concatenate '{0}{1:D3}' -f $Label, $Height — e.g. Sprint042, Alpha009, Beta015.
  9. Validate the result matches ^[A-Za-z0-9]+$ and throw otherwise.

The zero-padding to 3 digits is critical (see §4).


3. Why the prerelease must be alphanumeric

Update-ModuleManifest -Prerelease enforces the rule ^[A-Za-z0-9]+$. It rejects:

  • Sprint.42 — dot is illegal.
  • Sprint-42 — hyphen is illegal.
  • 42Sprint — must start with a letter (the regex above catches this in the parse step, not the prerelease check).
  • '' between manifests at different tiers — empty is allowed and means "stable release."

The PSGallery + ProGet PowerShellGet endpoint both honor SemVer 2.0 if the prerelease is well-formed, but they will not accept a .psd1 that Test-ModuleManifest itself rejects locally.


4. Why height is zero-padded to 3 digits

PowerShell Gallery sorts prereleases lexicographically, not numerically. Without padding, Sprint10 would sort before Sprint9, hiding the newer build under "older" listings. With 3-digit zero-pad:

Sprint001 < Sprint002 < ... < Sprint009 < Sprint010 < ... < Sprint099 < Sprint100

This works for heights 0–999. A sprint that produces 1000+ commits to a given path is unprecedented but would silently re-introduce the sort bug — tracked as a future improvement (4-digit pad, or switch to a SemVer 2.0 compliant gallery).


5. Ceiling-tier-to-label mapping

The five prerelease labels map directly to promotion ceiling tiers. This table is the authoritative reference for PowerShell module versioning; no other file needs to be consulted to understand the label-to-ceiling mapping.

Ceiling tierversion.json labelGenerated PrereleaseStages that execute at or below ceiling
ExperimentalSprint / feature labelSprintNNNExperimental only
DevelopmentAlphaAlphaNNNExperimental, Development
IntegrationBetaBetaNNNExperimental, Development, Integration
QAQAQANNNExperimental, Development, Integration, QA
Production (=Stable)(empty)(empty)Experimental through Production

Production vs Stable naming. The canonical tier name in pipeline, cmdlet, and run-state vocabulary is Production; the PowerShellGet feed a Production-tier module is published to is named PowershellGet-stable. The two names refer to the same tier — "Stable" is feed-side history, "Production" is pipeline-side canonical. The same naming convention applies to the C# NuGet topology (nuget-stable feed, Production tier). See VersionJsonAsCeiling.md for the canonical cross-ecosystem ceiling narrative.

The tier label is not stored anywhere PowerShell-specific. It is read from the module's version.json (the same NBGV file used by the C# build). Editing <ModuleRoot>/version.json and committing the change cuts a new candidate with a new ceiling (it produces a new artifact with a new version number). Moving an existing .nupkg between PowerShellGet feeds is a separate operation: Promote-ProGetPackage. That operation is documented in §7.


6. The version.json per module

Per the V4-D07 per-project placement policy (VersionJsonAsCeiling.md "Placement Policy"), each module folder owns its own version.json adjacent to the .psd1; there is no reliance on a repo-root file for a module's ceiling. Get-BuildContext reads the ceiling from this file and throws if it is absent.

Each module folder owns its own version.json:

{
  "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
  "version": "0.1-Sprint.{height}",
  "pathFilters": ["./", ":^./tests", ":^./_generated"],
  "nuGetPackageVersion": { "semVer": 2 }
}

Notes:

  • pathFilters scopes the height to commits affecting this module's files only. Without this, every commit anywhere in ATAP.Utilities would bump every module's height.
  • semVer: 2 is required for -Label.height syntax; SemVer 1 prereleases use a different separator and are not supported by the translation regex.
  • No per-module override of the prerelease label is allowed. The label must match exactly one of the five tiers in §5.

7. Promotion mechanics for PowerShell modules

Under immutable build, the version-label embedded in a published module .nupkg declares the ceiling tier the module may reach during this run. The current tier is which PowerShellGet feed and BuildMaster stage the .nupkg currently lives in. Movement between feeds is a Promote-ProGetPackage call — a ProGet API operation that copies the existing bytes (or moves a feed-membership pointer) from one feed to another. The .psd1 is not re-stamped, NBGV is not re-invoked, and version.json is not re-edited during a promotion.

7.0 Ceiling semantics of the prerelease label

version.json labelCeilingTierStages allowed for the same module package
Sprint or feature labelExperimentalExperimental only
AlphaDevelopmentExperimental, Development
BetaIntegrationExperimental, Development, Integration
QAQAExperimental, Development, Integration, QA
noneProductionExperimental through Production

Example: changing a module to "version": "0.1-Beta.{height}" and committing it cuts a fresh Integration-ceiling candidate. The next run builds and publishes the .nupkg once in Experimental, promotes the same bytes to Development and Integration, and then skips QA and Production.

7.0.1 BuildMaster run state for PowerShell module promotion

PowerShellModule-5Stage.otter derives the current BuildMaster build id with $BuildMasterId(build) and stores generated inter-stage state here:

_generated/buildmaster/<BuildMasterBuildId>/

The Experimental preamble captures the module's resolved package version and the Experimental stage writes the generated .nupkg path in that build-id folder. Later tiers use the captured $ResolvedPackageVersion from <ModuleName>.resolved-version.tmp; $PackageVersion is no longer an externally injected promotion input. Module build outputs remain under _generated/psmodules/<ModuleName>/; the buildmaster folder is only per-run state and diagnostic evidence.

This section is structured around the two distinct operations that earlier versions of this doc conflated.

7.1 The two operations are different

  • Cutting a new candidate at the next tier = edit <ModuleRoot>/version.json, commit, and let the next pipeline run produce a fresh .nupkg. This produces a new artifact with a new version number (e.g. moving from Sprint to Alpha makes the next build land at 0.1.0-Alpha.{newheight}). Use this when you want a fresh build under a different label. Procedure: §7.3.
  • Promoting an existing candidate = call Promote-ProGetPackage. The .nupkg's bytes are unchanged. The version number is unchanged. Only the feed membership changes. Use this when an artifact has passed its tier gate and is ready for the next feed. Procedure: §7.2.

7.2 Promotion procedure (Experimental → Development example)

The artifact 0.1.0-Alpha042 already exists in PowershellGet-experimental (because the developer who built it cut their candidate under the Alpha label — see §7.3). To make it official at the Development tier, promote it:

Promote-ProGetPackage `
    -Name     'ATAP.Utilities.FileIO.PowerShell' `
    -Version  '0.1.0-Alpha042' `
    -FromFeed 'PowershellGet-experimental' `
    -ToFeed   'PowershellGet-development' `
    -Reason   'DEV-PASS for build #4272'

Promote-ProGetPackage is the only mechanism for moving a PowerShell-module .nupkg between PowerShellGet feeds under the immutable build strategy. Promotion is not a re-pack, not a re-publish, and not a re-evaluation of version.json. See Immutable-Build-Strategy.md §5.

BuildMaster passes -CeilingTier to Promote-ProGetPackage, so attempting to promote beyond the label-derived ceiling fails before any ProGet API call.

7.3 Cutting a new candidate (formerly the "label promotion procedure")

Run this procedure when you want to change the label on a fresh build — e.g. you've been building Sprint candidates and now want to start producing Alpha candidates. It produces a new artifact with a new version number. It does not move an existing artifact between feeds.

To cut a new candidate for a single module under the next label:

$file = "src/ATAP.Utilities.FileIO.PowerShell/version.json"
(Get-Content $file -Raw) -replace '"version":\s*"0\.1-Sprint\.\{height\}"', '"version": "0.1-Alpha.{height}"' |
    Set-Content $file -Encoding utf8
git add $file
git commit -m "version(ps): ATAP.Utilities.FileIO.PowerShell cut new Alpha candidate"

To cut every PowerShell module under the next label at once:

Get-ChildItem ./src -Directory -Filter '*Powershell*','*PowerShell*','FinancialAPI' |
    ForEach-Object {
        $vj = Join-Path $_.FullName 'version.json'
        if (Test-Path $vj) {
            (Get-Content $vj -Raw) -replace 'Sprint', 'Alpha' | Set-Content $vj -Encoding utf8
        }
    }
git add src/*/version.json
git commit -m "version(ps): bulk cut new Alpha candidates for PowerShell modules"

After commit, the next nbgv get-version invocation in any of those module roots returns 0.1.0-Alpha.{newheight}. The build/pack/publish pipeline then publishes the resulting .nupkg to PowershellGet-experimental (the only publish target — see Pack-and-Publish doc §4). Movement of that new .nupkg to higher feeds happens by Promote-ProGetPackage per §7.2.

7.4 Two operations, two procedures

OperationWhenProcedure
Cut a new candidate at the next tierwhen you want a new artifact built under a new label§7.3
Promote an existing artifact between feedswhen an artifact has passed its tier gate§7.2

8. Stable (Production tier) special case

When version.json has no prerelease segment in version:

{ "version": "0.1" }

nbgv get-version --variable NuGetPackageVersion returns 0.1.0 (no hyphen, no label). The translation cmdlet:

  • Parses Major=0, Minor=1, Patch=0, Label=$null, Height=$null.
  • Sets ModuleVersion = [Version]'0.1.0'.
  • Sets Prerelease = ''.

Build-PSModuleManifest always passes -Prerelease to Update-ModuleManifest regardless of value — passing the empty string clears any pre-existing prerelease in the source manifest. This is intentional: it keeps the same code path for tier promotion to the Production tier.


9. Interaction with the manifest's authored ModuleVersion

Authored .psd1 templates carry a placeholder ModuleVersion (typically '0.0.4' or '0.1.0'). This value is always overwritten by Build-PSModuleManifest using the NBGV-computed version. Developers should not maintain the authored value — it exists only because PowerShell rejects manifests that omit ModuleVersion.


10. The git rev suffix at height 0

NBGV appends .g{shorthash} to the version string at height 0 (the very first commit on a label). Example: 0.1.0-Sprint.0.g1a2b3c4.

The translation regex matches this and discards the .g{hash} segment — the resulting Prerelease is Sprint000. This is by design: PSGallery rejects the git-hash suffix, and we never publish height-0 builds anyway (the first commit on a label is typically the label-change commit itself).


11. Common failures and remedies

ErrorCauseFix
The 'nbgv' CLI was not found on PATHNBGV global tool not installeddotnet tool install -g nbgv
nbgv output '...' does not match the expected patternversion.json uses an unsupported syntax (e.g. SemVer 1)Set nuGetPackageVersion.semVer to 2 and use -Label.height
Computed Prerelease '...' does not match the required alphanumeric patternLabel contains _ or -Edit version.json; labels must be ^[A-Za-z][A-Za-z0-9]*$
Update-ModuleManifest: Cannot bind parameter Prerelease ... legal characters are alphanumericHand-passed prerelease bypassed the translation cmdletAlways use Get-PSModuleVersionFromNBGV — never construct the prerelease manually
Two consecutive builds resolve different versionsFiles outside pathFilters were modifiedVerify pathFilters includes only this module's source

12. Known drift and gaps (sprint-0006)

  1. Some modules' version.json is missing pathFilters. Their height includes every commit to the entire repo, so the version bumps on every push. Tracked for cleanup.

  2. ATAP.Utilities.PowerShell.psd1 carries ModuleVersion = '0.0.4' in the authored template. This is harmless because Build-PSModuleManifest overwrites it, but it's misleading to readers.

  3. Height padding is 3 digits. A module that exceeds 999 path-scoped commits in a single label would silently re-sort (e.g. Alpha1000 < Alpha999). Switch to 4-digit pad before this becomes a problem.

  4. No PowerShell-specific version.json schema validator. A typo in the prerelease label (e.g. "Sprnit") is not caught until publish, where it lands in the wrong feed (PowershellGet-experimental accepts any prerelease shape).

  5. The translation cmdlet does not honor nuGetPackageVersion.precision. If a module's version.json overrides precision (e.g. to Major.Minor only), the parser still requires three integer segments. No module currently does this, but it's a latent foot-gun.


13. Quick reference

Get the version for one module:

Get-PSModuleVersionFromNBGV -ModuleRoot ./src/ATAP.Utilities.FileIO.PowerShell

# ModuleVersion Prerelease  FullNuGetVersion
# ------------- ----------  ----------------
# 0.1.0         Sprint042   0.1.0-Sprint.42

Inspect what NBGV would emit (without translation):

Push-Location ./src/ATAP.Utilities.FileIO.PowerShell
nbgv get-version --variable NuGetPackageVersion
nbgv get-version            # full table
Pop-Location

Promote one module to the next tier:

$file = './src/ATAP.Utilities.FileIO.PowerShell/version.json'
(Get-Content $file -Raw) -replace 'Sprint', 'Alpha' | Set-Content $file -Encoding utf8

14. Manifest Prerelease vs FullNuGetVersion

Source: Migrated from Explainers/0111-proget-feed-tier-dependency-build-report.md section "NBGV version files and tier selection".

Get-PSModuleVersionFromNBGV.ps1 derives three related-but-different values from a single NBGV invocation. The split exists because the PowerShell module manifest's prerelease format is stricter than the NuGet SemVer prerelease format that NBGV emits.

14.1 The three derived values

ValueExampleUsed for
FullNuGetVersion0.1.0-Sprint.42NuGet package identity / version
ModuleVersion0.1.0PowerShell manifest stable version field
PrereleaseSprint042PowerShell manifest prerelease field

14.2 Concrete example

Starting from a module version.json:

{
  "version": "0.1-Sprint.{height}",
  "nuGetPackageVersion": { "semVer": 2 },
  "pathFilters": ["./"],
  "publicReleaseRefSpec": [".*"]
}

NBGV (nbgv get-version --variable NuGetPackageVersion) emits a package version such as 0.1.0-Sprint.42. Get-PSModuleVersionFromNBGV then derives:

FieldValueOrigin
FullNuGetVersion0.1.0-Sprint.42Raw NBGV output — used as the .nupkg identity on ProGet
ModuleVersion0.1.0The Major.Minor.Patch triple — used as .psd1 ModuleVersion field
PrereleaseSprint042Label + zero-padded height — used as .psd1 Prerelease field

The Prerelease value is produced by '{0}{1:D3}' -f $Label, $Height — i.e. concatenate the label with the height zero-padded to 3 digits, and strip the dot separator. This is the only piece of "translation" the cmdlet performs; everything else is direct parsing.

When the gallery / ProGet republishes the package, it joins the two manifest fields as <ModuleVersion>-<Prerelease>0.1.0-Sprint042. This is not the same string as FullNuGetVersion (0.1.0-Sprint.42), but both identify the same artifact.

14.3 Why the split exists

NuGet SemVer prerelease labels can include dot-separated identifiers (Sprint.42 is valid SemVer 2.0). PowerShell module manifests use a stricter prerelease value: Update-ModuleManifest -Prerelease enforces ^[A-Za-z0-9]+$ (alphanumeric only — no ., no -, no underscores). So the code normalizes the NBGV label and height into an alphanumeric PowerShell prerelease string while preserving the original NBGV string for NuGet-side identity.

The split therefore reflects two different downstream consumers with two different rules, both fed from the same NBGV source of truth:

  • FullNuGetVersion travels with the .nupkg (NuGet's identity format).
  • ModuleVersion + Prerelease travel inside the .psd1 (PowerShell's stricter manifest format).

See §1 ("The two-format gap") and §3 ("Why the prerelease must be alphanumeric") for the rule details, and §4 ("Why height is zero-padded to 3 digits") for why the height transformation is necessary even when the label format itself would have been legal.