Repository navigation
Move version calculation to a Plan job (Resolve-PSModuleVersion) so Build and Publish never calculate or mutate versions #326
Description
Problem
Build-PSModule stamps every built module manifest with ModuleVersion = '999.0.0' as a placeholder.
The artifact is uploaded with this fake version, and all tests run against 999.0.0.
Later, Publish-PSModule (publish.ps1) calls Set-ModuleManifest to patch the real version into the manifest after all tests have passed.
# publish.ps1 mutates the artifact that was tested
Set-ModuleManifest -Path $manifestFilePath -ModuleVersion $manifestNewVersion
Set-ModuleManifest -Path $manifestFilePath -Prerelease $($newVersion.Prerelease)This means the artifact that was tested (999.0.0) is not the artifact that ships (for example 1.4.0).
That violates a core CI/CD principle: test what you ship.
Version calculation is also duplicated across Publish-PSModule/init.ps1 and Publish-PSModule/publish.ps1.
Moving version decision logic into one planning point simplifies maintenance and clarifies responsibilities.
Current Pipeline
Get-Settings
- reads configuration
- outputs settings
Build-PSModule
- stamps ModuleVersion = '999.0.0' (placeholder)
- uploads artifact
Test-Module / Test-ModuleLocal
- tests the 999.0.0 artifact (not the version that ships)
Publish-PSModule (init.ps1)
- queries GitHub Releases + PSGallery
- reads PR labels
- calculates new version (for example v1.4.0)
Publish-PSModule (publish.ps1)
- downloads artifact
- patches ModuleVersion in manifest (mutates tested artifact)
- publishes mutated artifact to PSGallery
- creates GitHub release
Desired Pipeline
Plan (single job, two steps)
- Get-PSModuleSettings
reads configuration and emits settings
- Resolve-PSModuleVersion
reads settings + PR labels
queries GitHub Releases + PSGallery
outputs Version / Prerelease / FullVersion / ReleaseType / CreateRelease
Build-PSModule
- receives Version + Prerelease from Plan
- stamps real version into manifest
- uploads immutable artifact
Test-Module / Test-ModuleLocal
- tests the same artifact that will be shipped
Publish-PSModule
- downloads artifact (already correctly versioned)
- publishes to PSGallery as-is
- creates GitHub release
flowchart LR
A[Plan<br/>Get-Settings + Resolve-PSModuleVersion] --> B[Build-Module<br/>stamps version into manifest]
B --> C[Test-* / Coverage]
C --> D[Publish-Module<br/>publishes artifact + uploads zip to release]
Why This Matters
| Concern | Current | Desired |
|---|---|---|
| Artifact integrity | Tested != Published | Tested = Published |
| Version decision point | Scattered in Publish (init.ps1 + publish.ps1) |
Single point in Plan (Resolve-PSModuleVersion) |
| Publish complexity | Calculates version, patches manifest, publishes | Publishes already-versioned artifact |
| Maintainability | Version logic in multiple scripts | Version logic in one action |
| Traceability | Artifact version not final until publish | Artifact version is authoritative |
Technical Decisions
Inputs Are Available At Planning Time
Everything needed to calculate the next version is available in the Plan job:
- PR labels (
major/minor/patch) ReleaseType(Release/Prerelease/None) from settings- Latest GitHub release (
gh release list) - Latest PSGallery version (
Find-PSResource)
Single Decision Point In One Job
The Plan job contains two steps:
Get-PSModuleSettingsloads and resolves settings.Resolve-PSModuleVersionconsumes settings + GitHub context and outputs:VersionPrereleaseFullVersionReleaseTypeCreateRelease
The Plan job surfaces both settings and version outputs for downstream jobs.
Build And Publish Become Executors
Build-PSModulestamps version information into the manifest and uploads the artifact.Publish-PSModulepublishes that artifact without mutating version metadata.
Consumer Compatibility
Process-PSModule/.github/workflows/workflow.yml keeps the same public input/output contract for external consumers.
This refactor is internal wiring.
Action-Level Breaking Change
Publish-PSModule removes version-calculation inputs and becomes a publish-only action.
That action receives a major version bump.
Direct users of Publish-PSModule must run Resolve-PSModuleVersion first.
Prerelease Counter
Prerelease sequencing keeps the same race characteristics as current behavior (no regression introduced by this plan).
Implementation Plan
Resolve-PSModuleVersion (new action repo)
Implemented in PSModule/Resolve-PSModuleVersion#1 ΓÇö merged.
- Create
PSModule/Resolve-PSModuleVersion(LICENSE, README,action.yml,src/main.ps1, tests). - Inputs:
Settings,Name,WorkingDirectory. - Outputs:
Version,Prerelease,FullVersion,ReleaseType,CreateRelease. - Port logic from
Publish-PSModule/src/init.ps1:- Install
PSSemVer. - Query GitHub releases.
- Query PSGallery.
- Parse PR labels (
major/minor/patch/ignore). - Apply bump logic.
- Handle incremental prerelease numbering.
- Honor
DatePrereleaseFormatandVersionPrefix.
- Install
- Add Pester tests for label parsing, bump selection, and prerelease sequencing.
Build-PSModule changes
Implemented in PSModule/Build-PSModule#136 ΓÇö merged.
- Accept
VersionandPrereleaseinputs. - Replace placeholder-only
ModuleVersionbehavior with:- passed-in version when provided
-
'999.0.0'fallback when not provided
- Stamp prerelease metadata when provided.
- Release as additive change (minor bump).
Publish-PSModule changes
Implemented in PSModule/Publish-PSModule#71 ΓÇö merged.
- Remove
src/init.ps1. - Remove manifest mutation (
Set-ModuleManifest) fromsrc/publish.ps1. - Read version from downloaded manifest (
Test-ModuleManifest+ prerelease metadata). - Remove version-calculation inputs from
action.yml. - Fix cleanup regression: snapshot
gh release listbeforepublish.ps1runs so the just-published release tag is not included in$tagsToDelete(PSModule/Publish-PSModule#71 review). - Fix
Compress-Archivepath: zip$modulePath(the folder) not$modulePath/*(its contents) so the archive extracts to a<Name>/directory (PSModule/Publish-PSModule#71 review). - Guard
Compress-Archiveinsideif (-not $whatIf)so WhatIf mode does not write a real zip file (PSModule/Publish-PSModule#71 review). - Validate
$manifest.Version.Build -ge 0before constructing the release tag to catch 2-part version strings (PSModule/Publish-PSModule#71 review). - Keep
cleanup.ps1behavior unchanged (gate cleanup on stable-release-only runs, not every invocation). - Release as major bump.
Process-PSModule workflow changes
Work in progress in PSModule/Process-PSModule#342 ΓÇö draft, awaiting end-to-end validation.
- Rename
Get-Settingsjob toPlan. - Keep
Get-PSModuleSettingsas first step inPlan. - Add
Resolve-PSModuleVersionas second step inPlan. - Expose Plan outputs:
Settings,Version,Prerelease,FullVersion,ReleaseType,CreateRelease. - Update
Build-Module.ymlto passVersionandPrereleaseintoBuild-PSModule. - Update
Publish-Module.ymlto remove version-calculation inputs. - Update
needsandwithwiring for downstream jobs to consume Plan outputs. - Repin
@mainreferences inBuild-Module.yml,Plan.yml, andPublish-Module.ymlto released SHAs once all companion repos are released.
Documentation
- Update
Process-PSModule/README.md:- show Plan job with two steps
- move version-bump explanation into Plan section
- document artifact-integrity guarantee
[ ] Add(not needed ΓÇö all changes are internal to the packaged workflow)Resolve-PSModuleVersion/README.mdwith inputs, outputs, and examples.[ ] Update(not needed ΓÇö all changes are internal to the packaged workflow)Publish-PSModule/README.mdmigration notes for removed inputs.
Test harness version-awareness (discovered 2026-07-08)
Because the built artifact now carries the real resolved version instead of the fixed 999.0.0 placeholder, the shared test and coverage tooling must stop assuming 999.0.0. On #342 the Plan and Build-Module jobs already succeed (the manifest is correctly stamped with the resolved version), but Test-Module, Test-ModuleLocal, Get-TestResults, and Build-Docs fail because the built module is installed into a 999.0.0 folder that no longer matches its manifest, so Import-Module rejects it as an invalid manifest.
End-to-end status (2026-07-08): with all four companion changes temporarily branch-pinned, the full Workflow-Test suite on #342 is green (Plan, Build-Module, Test-Module, Test-ModuleLocal, Get-CodeCoverage, Get-TestResults, Build-Docs — 61 pass / 7 publish-skipped / 0 fail). Remaining work is to merge + release the companion PRs and repin #342 to the released tags.
Merge order (post-approval — nothing merges without sign-off):
- PSModule/Test-PSModule#120 — Pester 6
-ForEachfix. Independent (fixes Test-PSModulemain, no version coupling); merge first. - PSModule/Install-PSModuleHelpers#19 — root install-version fix. Merge + release → new tag.
- PSModule/Test-PSModule#119 — after Post test result summary as PR comment #120 is merged and 🪲 [Fix]: Setting PowerShellVersion correctly after tests #19 is released: repin
Install-PSModuleHelpersto the release, then merge + release → new tag. - PSModule/Document-PSModule#52 — after 🪲 [Fix]: Setting PowerShellVersion correctly after tests #19 is released: repin
Install-PSModuleHelpersto the release, then merge + release → new tag. - PSModule/Process-PSModule#342 — after 🚀[Feature]: Merge results on module tests to get correct coverage stats #119/Support passing arbitrary secrets as environment variables to test jobs #52/🪲 [Fix]: Setting PowerShellVersion correctly after tests #19 are released: repin
Test-Module.yml→ Test-PSModule release,Build-Docs.yml→ Document-PSModule release,Test-ModuleLocal.yml→ Install-PSModuleHelpers release. Review + merge, then close this issue.
- Adapt
Plan.ymlto theResolve-PSModuleVersionv1.1.0 interface (v1.1.0 dropped theVersion/Prereleaseinputs and addedName) and repin it to v1.1.0. (done in 🚀 [Feature]: Plan job decides version before build so tested artifact equals published artifact #342) -
Install-PSModuleHelpers:Install-PSModule(src/Helpers/Helpers.psm1) copies the built module into a hard-coded.../<name>/999.0.0folder beforeImport-Module, which fails once the manifest carries the real version. Install into the folder that matches the manifestModuleVersion(via the existingGet-ModuleManifest), then release and repin. BlocksTest-ModuleandTest-ModuleLocal. Status (2026-07-08): implemented in PSModule/Install-PSModuleHelpers#19 (own CI green; Copilot review converged; also hardened against path traversal from a malformed manifest version). Validated end-to-end on 🚀 [Feature]: Plan job decides version before build so tested artifact equals published artifact #342 via a temporary branch pin —Test-ModuleLocalis green. Awaiting merge + release, then repin to the released tag. -
Process-PSModule/.github/workflows/Test-ModuleLocal.yml: replace the hard-codedImport-Module -RequiredVersion 999.0.0with the resolved version from the Plan settings. Status (2026-07-08): done on 🚀 [Feature]: Plan job decides version before build so tested artifact equals published artifact #342 (the Prescript now importsfromJson(Settings).Module.Version);Install-PSModuleHelpersis temporarily pinned to the 🪲 [Fix]: Setting PowerShellVersion correctly after tests #19 branch until it releases. -
Get-PesterCodeCoverage: coverage file paths are normalized by splitting on999.0.0; derive the version dynamically so coverage paths resolve with real versions, then release and repin. Status (2026-07-08): likely not required for green —Get-CodeCoverageandGet-TestResultspass on 🚀 [Feature]: Plan job decides version before build so tested artifact equals published artifact #342 with real versions. The split just no longer strips a prefix, so coverage file paths may be absolute rather than module-relative (cosmetic). To confirm/clean up as a follow-up. -
Test-PSModule: re-release and repin if required after theInstall-PSModuleHelpersfix (it depends onInstall-PSModule). Status (2026-07-08): implemented in PSModule/Test-PSModule#119 (bumps theInstall-PSModuleHelperspin).Test-Module(andGet-TestResults/Get-CodeCoverage) validated green on 🚀 [Feature]: Plan job decides version before build so tested artifact equals published artifact #342 via a temporary branch pin; Copilot review converged. Test-PSModule's ownAction-Test-outputswas pre-existing red onmain(a Pester 6 empty-ForEachinPSModule.Tests.ps1, unrelated to this refactor); fixed separately in PSModule/Test-PSModule#120, which must merge before 🚀[Feature]: Merge results on module tests to get correct coverage stats #119. -
Document-PSModule: repinInstall-PSModuleHelpers(it also installs the built module viaInstall-PSModule, forBuild-Docs). Status (2026-07-08): implemented in PSModule/Document-PSModule#52; own CI green; Copilot review converged;Build-Docsvalidated green on 🚀 [Feature]: Plan job decides version before build so tested artifact equals published artifact #342 via a temporary branch pin. Awaiting merge + release, then repin.
Validation
- Verify built artifact manifest contains final version before publish.
- Verify publish logs contain no manifest mutation.
- Verify prerelease cleanup behavior is unchanged.
- Run end-to-end in
Process-PSModulewith a labeled PR. - Smoke test one downstream consumer workflow.
- Verify the built module installs into a version folder that matches its stamped manifest version (no
999.0.0mismatch). - Confirm
Test-Module,Test-ModuleLocal,Get-TestResults, andBuild-Docspass end-to-end on 🚀 [Feature]: Plan job decides version before build so tested artifact equals published artifact #342.
Note
This refactor centralizes version decisions in the Plan job (Resolve-PSModuleVersion), improving maintainability while enforcing tested-artifact equals shipped-artifact behavior.
Activity
[-]Extract shared versioning and release logic from Publish-PSModule and Release-GHRepository[/-][+]Move version calculation to Build-PSModule so Publish-PSModule never mutates the artifact[/+]MariusStorhaug commentedon May 16, 2026
Rewrote the issue description completely to reflect the correct problem framing.
What changed:
999.0.0) is never identical to the published artifact (the real version). DRY is now described as a downstream consequence, not the root issue.Build-PSModulebecomes the single version authority, whatPublish-PSModulereduces to, wherePSSemVermoves, how workflow inputs shift, and the breaking-change implications.999.0.0pipeline diagram was removed from the body; the comparison table in Section 1 conveys the same information more concisely.[-]Move version calculation to Build-PSModule so Publish-PSModule never mutates the artifact[/-][+]Move version calculation to Plan-PSModule so Build and Publish never calculate or mutate versions[/+][-]Move version calculation to Plan-PSModule so Build and Publish never calculate or mutate versions[/-][+]Move version calculation to a Plan job (Resolve-PSModuleVersion) so Build and Publish never calculate or mutate versions[/+]🪲 [Fix]: Restore correct release versioning in the publish pipeline (#…
MariusStorhaug commentedon Jul 9, 2026
PR #342 — pulled from
main, dependency audit, and full Copilot review ✅Branch head:
dac345c· Status: MERGEABLE, awaiting maintainer approval (do not self-merge).Pulled from
mainMerged latest
origin/maininto the branch, including #367 (GitHub-Script v1.9.0) and #365 (TestData secret model). README conflicts resolved keeping both the #368 "Dependency tree" and #342 "Plan" content.Full dependency audit (all workflow action pins)
All pins verified current. Three stale pins bumped to their released tags:
Build-Site.yml: Install-PSModuleHelpersv1.0.7 → v1.0.8Test-SourceCode.yml: Test-PSModulev3.0.10 → v3.0.13Test-ModuleLocal.yml: Invoke-Pester→ v4.2.6Final companion versions: Resolve-PSModuleVersion
v1.1.0, Build-PSModulev5.0.0, Publish-PSModulev3.0.0, Test-PSModulev3.0.13, Install-PSModuleHelpersv1.0.8, Document-PSModulev1.0.17, Invoke-Pesterv4.2.6.CI
Green on
dac345c: 61 pass / 7 skip / 0 fail across both Workflow-Test suites (Default + WithManifest) plus linters, CodeQL, and Release.Copilot review — converged (round 11: "generated no new comments", 0 unresolved threads)
Fixes applied during the loop:
workflow_callsecret declarationAPIKEY → APIKey(cosmetic; GitHub secret names are case-insensitive).ReleaseTypeoutput reference withSettings.Module.ReleaseType/CreateRelease.Test-ModuleLocal.yml: quoted-RequiredVersion.Build-Module.yml: aligned the no-version fallback0.0.0 → 999.0.0(matches the documented placeholder); reconciled the PR description (singleSettingsoutput withSettings.Module.*, v1.1.0 pin, unconditional Resolve-Version).Two comments were false positives, verified and resolved with evidence:
moduleartifact internally (download-artifact→outputs/module), so tested bytes == published bytes holds.needs/steps/jobs; confirmed by green CI executing those exact expressions.This is the final item in the merge order for #326. Ready for maintainer review + merge.
🚀 [Feature]: Plan job decides version before build so tested artifact…