This guide assumes Powershell 7+ (works on 5.1 too with one or two adjustments noted), git, and that you've published at least one module before even just to a file share.

The first ten Powershell modules anyone writes are an .psm1 file with everything in it. The eleventh starts to want structure, tests, automatic builds, a changelog, semantic versioning, and a CI pipeline. Sampler is the scaffolding tool that gives you all of that in one New-SampleModule call, with sensible defaults that mirror how the largest open-source Powershell projects (DSC Community, Microsoft FAQ modules) actually ship code.

This is the long version: the layout, every component, the build.yaml knobs that matter, the Plaster template options, and how to wire it up to GitHub Actions or Azure DevOps end to end.

What Sampler Bundles

Sampler is a Plaster template (and a small support module) that scaffolds a project pre-wired with:

Component Role
InvokeBuild Build orchestration the Invoke-Build task runner
ModuleBuilder Composes source/*.ps1 into one .psm1
Pester 5 Unit + integration tests
PSScriptAnalyzer Linting
PlatyPS Markdown docs + external help XML
GitVersion Semantic version derived from git history
ChangelogManagement Keep-A-Changelog parser + bumper
Sampler.GitHubTasks Releases, draft PRs, milestone management
DscResource.Test "High Quality Resource Module" rule pack
Plaster Re-runnable scaffolding for new sub-pieces

Crucially, all of these are managed automatically via RequiredModules.psd1 and Resolve-Dependency.psd1. A clean clone bootstraps itself with one command.

Sampler scaffolds script modules. For binary modules in C#, see the C# module series. The two layouts are different on purpose; pick before you scaffold.

Install

Install-PSResource -Name Sampler -Repository PSGallery -Scope CurrentUser -TrustRepository

Sampler is itself a module its templates ship inside it.

Scaffold a New Module

$sampleParams = @{
    DestinationPath   = 'C:\src\MyOps'
    ModuleType        = 'CompleteSample'
    ModuleName        = 'MyOps'
    ModuleAuthor      = 'Ricardo Martin'
    ModuleDescription = 'Operational helpers'
    ModuleVersion     = '0.0.1'
    CustomRepo        = 'PSGallery'
    LicenseType       = 'MIT'
    SourceDirectory   = 'source'
}
New-SampleModule @sampleParams

-ModuleType is a ValidateSet the accepted values are:

ModuleType Use case
SimpleModule Bare module with pipeline automation (the default)
SimpleModule_NoBuild Single .psm1, no compose step
CompleteSample Everything: tests, docs, changelog, CI
dsccommunity DSC Community style strict, includes HQRM tests

CompleteSample is the right default for almost any new project.

Two other parameters worth knowing:

  • -LicenseType MIT, Apache, or None.
  • -SourceDirectory source (default) or src.
  • -Features an alternative parameter set (ByFeature) that lets you cherry-pick from Enum, Classes, DSCResources, ClassDSCResource, SampleScripts, git, Gherkin, UnitTests, ModuleQuality, Build, AppVeyor, TestKitchen, or All instead of picking a whole ModuleType.

What You Get The Layout

MyOps/
├── source/
│   ├── MyOps.psd1              # the manifest *template*
│   ├── Classes/                # PS classes - composed first
│   │   └── 01.Widget.ps1
│   ├── Enum/                   # composed before classes
│   │   └── Severity.ps1
│   ├── Public/                 # exported functions
│   │   └── Get-Widget.ps1
│   ├── Private/                # internal helpers
│   │   └── Format-Internal.ps1
│   └── en-US/                  # localized strings
│       └── MyOps.strings.psd1
├── tests/
│   ├── QA/                     # PSScriptAnalyzer + manifest tests
│   └── Unit/
│       └── Public/
│           └── Get-Widget.tests.ps1
├── output/                     # build artifacts (gitignored)
├── .build/                     # InvokeBuild task scripts
├── build.yaml                  # the brain
├── build.ps1                   # bootstrapper
├── RequiredModules.psd1        # build-time module deps
├── Resolve-Dependency.psd1     # how to resolve them
├── GitVersion.yml              # semantic versioning config
├── CHANGELOG.md                # Keep-A-Changelog format
├── README.md
├── LICENSE
└── azure-pipelines.yml         # or GH Actions, see below

Every part is intentional and configurable through build.yaml.

The Build Bootstrapper

# Bootstraps everything: dependencies, build, test, package
./build.ps1 -ResolveDependency -Tasks build

build.ps1 is a thin wrapper that:

  1. Reads Resolve-Dependency.psd1 and installs everything in RequiredModules.psd1 into ./output/RequiredModules.
  2. Loads build.yaml.
  3. Hands off to Invoke-Build with the requested tasks.

./output/RequiredModules is local to the project. This is why a clean clone "just works" without polluting your global module path. The downside: it can be a few hundred MB. Add output/ to .gitignore (Sampler does this for you).

build.yaml The Single Source of Truth

The single most important file in a Sampler project. Every component reads from it.

####################################################
#          ModuleBuilder Configuration             #
####################################################
CopyPaths:
  - en-US
  - DSCResources
Encoding: UTF8
VersionedOutputDirectory: true

####################################################
#       Sampler Pipeline Configuration             #
####################################################
BuildWorkflow:
  '.':
    - build
    - test

  build:
    - Clean
    - Build_Module_ModuleBuilder
    - Build_NestedModules_ModuleBuilder

  pack:
    - build
    - docs
    - package_psresource_nupkg

  test:
    - Pester_Tests_Stop_On_Fail
    - Convert_Pester_Coverage
    - hqrmtest

  publish:
    - Publish_release_to_GitHub
    - publish_module_to_gallery

####################################################
#                Pester                            #
####################################################
Pester:
  Configuration:
    Run:
      Path:
        - tests/Unit
    Output:
      Verbosity: Detailed
    CodeCoverage:
      CoveragePercentTarget: 75
      OutputPath: JaCoCo_coverage.xml
      OutputEncoding: ascii
      UseBreakpoints: false
    TestResult:
      OutputFormat: NUnitXml
      OutputEncoding: ascii
      OutputPath: NUnitXml_Test_Results.xml
  ExcludeFromCodeCoverage:
    - Modules/DscResource.Common

####################################################
#                ModuleBuilder                     #
####################################################
ModuleBuildTasks:
  Sampler:
    - '*.build.Sampler.ib.tasks'
  Sampler.GitHubTasks:
    - '*.ib.tasks'

####################################################
#                ChangelogManagement               #
####################################################
ChangelogConfig:
  FilesToIncludeInChangeLog:
    - CHANGELOG.md
  UpdateChangelogOnPrerelease: false

####################################################
#                GitHub Configuration              #
####################################################
GitHubConfig:
  GitHubFilesToAdd:
    - 'CHANGELOG.md'
  GitHubConfigUserName: rcfmartin
  GitHubConfigUserEmail: [email protected]
  UpdateChangelogOnPrerelease: false

The BuildWorkflow block defines named task aliases. Calling ./build.ps1 -Tasks pack runs build (which itself runs Clean → Build_Module → ...), then docs, then package_psresource_nupkg. Compose any pipeline you need from the available tasks the names are passed straight to Invoke-Build.

ModuleBuilder The Compose Step

ModuleBuilder is the part that surprises people coming from one-big-.psm1 modules. You write each function in its own .ps1 file under source/Public/, source/Private/, etc. at build time, ModuleBuilder concatenates them, in a deterministic order, into a single output/MyOps/<version>/MyOps.psm1.

Composition order:

  1. source/Header.ps1 (if present)
  2. source/Enum/*.ps1 (sorted)
  3. source/Classes/*.ps1 (sorted by name that's why people prefix 01., 02. for inheritance order)
  4. source/Private/**/*.ps1
  5. source/Public/**/*.ps1
  6. source/Footer.ps1 (if present)
  7. The Export-ModuleMember -Function (Get-ChildItem source/Public/*.ps1).BaseName line is auto-generated.

Public functions are auto-exported by filename. Drop Get-Widget.ps1 in source/Public/ and Get-Widget is exported. No Export-ModuleMember to maintain. This is the single biggest readability win over hand-rolled module structures.

The composed .psm1 is what ships. It's a single file at runtime fast to load, easy to debug but you maintain it as one-function-per-file.

Manifest Templating

source/MyOps.psd1 is a Plaster-templated manifest. ModuleBuilder rewrites it during the build:

@{
    RootModule        = 'MyOps.psm1'
    ModuleVersion     = '0.0.1'         # replaced with GitVersion result
    GUID              = '...'
    Author            = 'Ricardo Martin'
    Description       = 'Operational helpers'
    PowerShellVersion = '5.1'
    FunctionsToExport = @()             # replaced from source/Public/*.ps1
    CmdletsToExport   = @()
    VariablesToExport = @()
    AliasesToExport   = @()
    PrivateData = @{
        PSData = @{
            Tags         = @('PSEdition_Desktop', 'PSEdition_Core')
            ProjectUri   = 'https://github.com/rcfmartin/MyOps'
            LicenseUri   = 'https://github.com/rcfmartin/MyOps/blob/main/LICENSE'
            ReleaseNotes = ''           # replaced from CHANGELOG.md
            Prerelease   = ''           # filled in for prerelease builds
        }
    }
}

Don't edit ModuleVersion, FunctionsToExport, or ReleaseNotes by hand they're rewritten on every build.

GitVersion Semantic Versioning From Git

GitVersion.yml config:

mode: ContinuousDeployment
next-version: 1.0.0
branches:
  main:
    tag: ''
    increment: Patch
  development:
    tag: preview
    increment: Minor
  pull-request:
    tag: PR
ignore:
  sha: []

What it does at build time:

  • Walks the git history.
  • Infers the next version from commit messages (+semver: major, +semver: minor, conventional commits).
  • Stamps that version into MyOps.psd1 and output/MyOps/<version>/.

A commit message like:

Add Get-Widget cmdlet

+semver: minor

bumps the minor version on the next build. Tag-driven workflows work too git tag v1.4.7 → next build is exactly 1.4.7.

Stop choosing version numbers by hand. GitVersion + commit message hints means the version is always derivable from the repo. CI never has to ask "what version is this?"

ChangelogManagement Keep-A-Changelog as a Build Artifact

CHANGELOG.md follows the Keep-A-Changelog format:

# Changelog

## [Unreleased]
### Added
- `Get-Widget` cmdlet for fetching widgets.

### Fixed
- Edge case in `Format-Internal` when input is empty.

## [1.4.6] - 2026-03-15
### Added
- Initial widget support.

The Create_changelog_release_output build task:

  1. Parses the [Unreleased] block.
  2. Stamps it into the manifest's ReleaseNotes.
  3. On a release, moves it under a new [<version>] <date> heading.
  4. Optionally opens a PR with the changelog update.

You write changelog entries as part of every PR (a Sampler-generated CI check enforces it). The release flow is fully derived.

Pester Pre-Wired Test Layout

The tests/QA/ folder ships with sanity tests Sampler runs by default:

  • module.tests.ps1 manifest validity, function exports match source/Public/.
  • ScriptAnalyzer.tests.ps1 runs PSScriptAnalyzer against output/MyOps/<version>, fails on errors.
  • help.tests.ps1 every public function must have comment-based help.

For your own tests, the convention is one *.tests.ps1 per *.ps1:

source/Public/Get-Widget.ps1
tests/Unit/Public/Get-Widget.Tests.ps1

The test file:

BeforeAll {
    $script:dscModuleName = 'MyOps'
    Import-Module -Name $script:dscModuleName -Force -ErrorAction Stop
}

AfterAll {
    Remove-Module -Name $script:dscModuleName -Force
}

Describe 'Get-Widget' {
    It 'returns a widget by name' {
        $w = Get-Widget -Name 'foo'
        $w | Should -Not -BeNullOrEmpty
    }
}

Pester runs against the built module from output/, not the source files. This catches "works in the editor, breaks after compose" bugs that one-.psm1 modules don't have.

Refresher on Pester patterns lives in the Pester post. Sampler doesn't change Pester it just configures it.

PlatyPS Markdown Docs as the Source of Truth

./build.ps1 -Tasks docs generates docs/MyOps/Get-Widget.md from each function's comment-based help. Edit the markdown, regenerate the help XML, ship both.

# Generate markdown from existing help
New-MarkdownHelp -Module MyOps -OutputFolder ./docs

# Update markdown when functions change
Update-MarkdownHelp ./docs

# Generate the .xml that Get-Help reads
New-ExternalHelp -Path ./docs -OutputPath ./output/MyOps/<version>/en-US

The Publish_GitHub_WikiContent task pushes the markdown to your GitHub wiki on release. The wiki and the in-shell Get-Help are the same content, generated once.

Required Modules

RequiredModules.psd1:

@{
    # PSDependOptions is only used if you fall back to PowerShellGet + PSDepend.
    # With the modern default (PSResourceGet, see Resolve-Dependency.psd1) it's ignored.
    #PSDependOptions                = @{
    #    AddToPath  = $true
    #    Target     = 'output\RequiredModules'
    #    Parameters = @{ Repository = 'PSGallery' }
    #}

    InvokeBuild                          = 'latest'
    PSScriptAnalyzer                     = 'latest'
    Pester                               = 'latest'
    Plaster                              = 'latest'
    ModuleBuilder                        = 'latest'
    MarkdownLinkCheck                    = 'latest'
    ChangelogManagement                  = 'latest'
    'Sampler.GitHubTasks'                = 'latest'
    'DscResource.Test'                   = 'latest'
    'DscResource.AnalyzerRules'          = 'latest'
    xDscResourceDesigner                 = 'latest'
    PlatyPS                              = 'latest'
    'DscResource.DocGenerator'           = 'latest'
    'Microsoft.PowerShell.PSResourceGet' = 'latest'
}

Resolve-Dependency.psd1 controls how they get installed. The modern defaults:

@{
    Gallery          = 'PSGallery'
    AllowPrerelease  = $false
    WithYAML         = $true
    UsePSResourceGet = $true
    # UseModuleFast          = $true      # alternative, commented by default
    # ModuleFastBleedingEdge = $false
    # PSResourceGetVersion   = 'latest'
    # RegisterGallery        = @{ ... }   # private NuGet feeds
}

UsePSResourceGet = $true is the current Sampler default, replacing the older PowerShellGet + PSDepend bootstrap. PSResourceGet (formerly PowerShellGet v3) parallelizes dependency installs natively a clean-clone bootstrap drops from roughly a minute to ~10–20s.

To revert to PowerShellGet + PSDepend, remove the UsePSResourceGet line (or set it to $false) and un-comment the PSDependOptions block in RequiredModules.psd1.

Pre-built Tasks Worth Knowing

The *.ib.tasks files inside Sampler ship dozens of named tasks. The ones you'll use most:

Task What it does
Clean Wipe output/MyOps/
Build_Module_ModuleBuilder Compose source → .psm1 + manifest stamping
Build_NestedModules_ModuleBuilder Same, for NestedModules under output/
Pester_Tests_Stop_On_Fail Run Pester, fail build on red
Convert_Pester_Coverage Convert Pester coverage output for CI
hqrmtest Run the HQRM (DSC High-Quality Resource Module) rule pack
package_psresource_nupkg Pack the module folder as a PSResource nupkg
Publish_release_to_GitHub Create a GitHub release with changelog notes
publish_module_to_gallery Publish to PSGallery (or the CustomRepo feed)
Publish_GitHub_WikiContent Push docs to GitHub wiki

Mix and match in BuildWorkflow: aliases.

CI GitHub Actions

Sampler scaffolds .github/workflows/release.yml for the CompleteSample template. The shape:

name: Release
on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

jobs:
  build_test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }      # GitVersion needs full history
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '8.x' }
      - shell: pwsh
        run: ./build.ps1 -ResolveDependency -Tasks build,test
      - uses: actions/upload-artifact@v4
        with:
          name: module-${{ matrix.os }}
          path: output/MyOps/

  publish:
    needs: build_test
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - shell: pwsh
        env:
          GITHUB_TOKEN:    ${{ secrets.GITHUB_TOKEN }}
          GalleryApiToken: ${{ secrets.PSGALLERY_TOKEN }}
        run: ./build.ps1 -ResolveDependency -Tasks publish

Three things worth noting:

  • fetch-depth: 0 GitVersion requires the full git history. With the default shallow clone it can't compute the version.
  • Matrix builds across Windows / Ubuntu / macOS Sampler's defaults work cross-platform out of the box.
  • Tag-triggered publish branch builds test; only git tag v1.4.7 ships.

CI Azure DevOps

The azure-pipelines.yml Sampler generates is similar same task structure, just YAML for pwsh script steps:

trigger:
  branches: { include: ['main'] }
  tags:     { include: ['v*'] }

stages:
  - stage: Build
    jobs:
      - job: Build_Test
        strategy:
          matrix:
            Linux:   { vmImage: 'ubuntu-latest' }
            Windows: { vmImage: 'windows-latest' }
        pool: { vmImage: $(vmImage) }
        steps:
          - checkout: self
            fetchDepth: 0
          - pwsh: ./build.ps1 -ResolveDependency -Tasks build,test
          - publish: output/MyOps
            artifact: 'module-$(Agent.OS)'

  - stage: Deploy
    dependsOn: Build
    condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/v'))
    jobs:
      - deployment: Publish
        environment: 'production'
        strategy:
          runOnce:
            deploy:
              steps:
                - pwsh: ./build.ps1 -ResolveDependency -Tasks publish
                  env:
                    GalleryApiToken: $(GalleryApiToken)
                    GitHubToken:     $(GitHubToken)

Adding to an Existing Project

New-SampleModule scaffolds new projects. For incremental additions to an existing module, Sampler exposes two entry points:

  • New-SamplerPipeline add the build/test/release pipeline to a project that wasn't scaffolded with Sampler originally.
  • Add-Sample the more granular one, drops a specific Plaster sub-template into the current project.

The sub-templates that ship with Sampler (Sampler/Templates/):

Build                       ClassFolderResource        Classes
Composite                   Enum                       Examples
GCPackage                   Git                        GithubConfig
HelperSubModules            MofResource                PrivateFunction
PublicCallPrivateFunctions  PublicFunction             Sampler
VscodeConfig                ChocolateyPackage          ChocolateyPipeline
ClassResource

Typical uses: Add-Sample with PublicFunction to drop a new function skeleton (including its Pester test); MofResource or ClassResource to add a DSC resource; ChocolateyPipeline to add Chocolatey packaging; GithubConfig to drop the GitHub Actions workflow into an existing repo.

A common path: scaffold with -ModuleType SimpleModule, then Add-Sample the pieces you need as the project grows.

Build Once, Run Locally

The full inner-loop:

# First time
./build.ps1 -ResolveDependency -Tasks noop

# Build + test
./build.ps1 -Tasks build,test

# Just build
./build.ps1 -Tasks build

# Just test
./build.ps1 -Tasks test

# Show all available tasks
./build.ps1 -Help

After a successful build, the module is at output/MyOps/<version>/. To use it locally:

Import-Module ./output/MyOps/<version>/MyOps.psd1 -Force
Get-Widget -Name foo

-Force matters without it, Powershell uses the previously loaded copy.

Common Pitfalls

  • Editing the manifest by hand and losing changes. ModuleBuilder rewrites it on every build. Put your edits in source/MyOps.psd1 (the template) instead.
  • Forgetting fetch-depth: 0 in CI. GitVersion fails silently and your version stays at 0.0.1.
  • One function spread across multiple .ps1 files. ModuleBuilder concatenates everything; class definitions split across files don't survive composition. One function or class per file.
  • Mixing Pester 4 and 5 syntax. Sampler is Pester 5. The Pester post covers the trap.
  • Publishing without rotating the gallery API key. The GalleryApiToken secret is long-lived; treat it like the PSK keys in the PSK/TLS post rotate it periodically.
  • Adding output/ to git. It's intentionally .gitignored. If you commit it, every build creates a giant diff.

Why This Matters

The five things Sampler quietly solves that nobody writes scripts to fix on their own:

  1. One function per file is more maintainable than one massive .psm1. ModuleBuilder reconciles that with "ship one fast file at runtime."
  2. Versions derived from git stop drift between manifest, tag, gallery, and changelog.
  3. Pester runs against the built artifact catches every "works in source, breaks at runtime" bug.
  4. CI is identical to local. Sampler's ./build.ps1 is the exact same script CI runs. No "it worked in the pipeline" mysteries.
  5. The default rule set is what HQRM modules use. You inherit the bar that the most rigorous Powershell open-source projects already meet.

Production-Grade Reference Modules to Steal From

The clearest way to learn Sampler is reading existing modules built with it. A few worth pulling down:

  • DSC Community modules (PSDesiredStateConfiguration, ComputerManagementDsc, ActiveDirectoryDsc) the original consumers of Sampler, set the conventions.
  • Sampler itself bootstrapped with itself.
  • Datum (config compiler) non-DSC use of Sampler at scale.

Clone any of these, run ./build.ps1 -ResolveDependency -Tasks build, and watch what each task does. Reading the build.yaml of a real project teaches more than any docs site.

What to Do Next

Sampler isn't magic it's a curated bundle of tools (Plaster, InvokeBuild, ModuleBuilder, Pester, PSScriptAnalyzer, GitVersion, ChangelogManagement, PlatyPS) wired together with sensible defaults. The win is that every Powershell module you start from New-SampleModule -ModuleType CompleteSample has the same layout, the same build commands, the same CI shape, and the same release process. Once a team adopts it, contributing to a new module feels exactly like contributing to the last one and that's the productivity multiplier nobody talks about until they've had it.

Three concrete moves to adopt Sampler for your team this week:

  1. Generate a single throwaway module with New-SampleModule -ModuleType CompleteSample. Don't try to retrofit an existing repo on the first pass. Run ./build.ps1 -ResolveDependency -Tasks build,test against the generated skeleton, watch what each task does, and read the build.yaml line by line. That's the curriculum.
  2. Pick one existing module and migrate it. The boring one without active changes. Move sources into source/Public and source/Private, copy the build.yaml, run the tasks, fix what breaks. The first migration is the painful one; the next ten are mechanical.
  3. Standardise the CI on the same build.ps1 invocation. Whatever pipeline you use (GitHub Actions, Azure DevOps, GitLab, Jenkins), the job is the same three lines: bootstrap dependencies, run build.ps1 -Tasks build,test, publish on tags. Once every module's CI is one line different, onboarding a new module to CI takes minutes, not days.

Pairs naturally with the Pester tests post (which is already wired in by default) and the Script Analyzer post (which Sampler runs as part of every build, so the linter rules you set there apply to every module the moment it's bootstrapped).