Browse Source

ci: publish releases from CI on a version tag, starting with 5.0.0-rc1

Pushing a tag such as 5.0.0 or 5.0.0-rc1 on master now publishes the
package that the build job built and tested to the PowerShell Gallery
and creates the GitHub release with NTFSSecurity.zip.

- New-ModulePackage.ps1 copies only the FileList files of the Release
  build, so no debug symbols, XML documentation, or copy of
  System.Management.Automation.dll ship. It builds the nupkg with
  Compress-PSResource and adds the command tags (PSIncludes_Cmdlet,
  PSCmdlet_*, PSCommand_*) that PSResourceGet leaves out and that the
  Gallery uses to list cmdlets and that Find-Command searches. Every CI
  run builds and uploads the packages.
- Get-ReleaseInfo.ps1 returns the version and release notes: a dated
  CHANGELOG section for a release, the [Unreleased] section for a
  prerelease.
- The release job checks that the tag matches the manifest version and
  points to a commit on master, reads the API key from the environment
  powershell-gallery, and skips steps already done, so a rerun is safe.
- The manifest gets the prerelease label rc1 and a release notes link;
  the 5.0.0 changelog entries move back to [Unreleased] until the final
  release.
- Tests/Release.Tests.ps1 covers both scripts and the packages; the
  changelog check moves there from Manifest.Tests.ps1.
- Docs/Contributing/05-Releasing.md describes the one-time setup and the
  release steps; Docs/README.md explains -AllowPrerelease.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: AI Assistant <ai@example.com>
pull/98/head
Raimund Andree 1 week ago
parent
commit
87f237c696
  1. 119
      .github/scripts/Get-ReleaseInfo.ps1
  2. 148
      .github/scripts/New-ModulePackage.ps1
  3. 124
      .github/workflows/ci.yml
  4. 19
      CHANGELOG.md
  5. 3
      Docs/Contributing.md
  6. 2
      Docs/Contributing/02-Writing.md
  7. 110
      Docs/Contributing/05-Releasing.md
  8. 3
      Docs/README.md
  9. 3
      NTFSSecurity/NTFSSecurity.psd1
  10. 10
      Tests/Manifest.Tests.ps1
  11. 255
      Tests/Release.Tests.ps1

119
.github/scripts/Get-ReleaseInfo.ps1

@ -0,0 +1,119 @@
<#
.SYNOPSIS
Returns the version of the module and its release notes from CHANGELOG.md.
.DESCRIPTION
Reads ModuleVersion and the prerelease label (PrivateData.PSData.Prerelease) from the module manifest and returns
the version to release, such as 5.0.0 or 5.0.0-rc1, with its release notes:
- A release without a prerelease label takes the notes of the section "## [<version>] - <yyyy-MM-dd>". The
section must exist and have a release date.
- A prerelease takes the notes of the section "## [Unreleased]", which must not be empty. CHANGELOG.md must not
have a section for the version yet; it gets one with the final release.
The notes are the text below the heading up to the next section or up to the link definitions at the end of the
file, with line feeds as line breaks.
.PARAMETER ManifestPath
Specifies the module manifest.
.PARAMETER ChangelogPath
Specifies CHANGELOG.md.
.EXAMPLE
.\.github\scripts\Get-ReleaseInfo.ps1 -ManifestPath .\NTFSSecurity\NTFSSecurity.psd1 -ChangelogPath .\CHANGELOG.md
Returns the version, whether it is a prerelease, the release date, and the release notes.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param (
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
[string]
$ManifestPath,
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
[string]
$ChangelogPath
)
$ErrorActionPreference = 'Stop'
function Get-ChangelogSection {
<#
Returns the date and the notes of the section with the given name, such as 5.0.0 or Unreleased, or nothing if
CHANGELOG.md has no such section.
#>
param (
[Parameter(Mandatory)]
[string]
$Changelog,
[Parameter(Mandatory)]
[string]
$Name
)
$pattern = '(?ms)^## \[{0}\](?:[ \t]+-[ \t]+(?<Date>\S+))?[ \t]*$(?<Notes>.*?)(?=^## |^\[[^\]]+\]:[ \t]|\z)' -f
[regex]::Escape($Name)
$match = [regex]::Match($Changelog, $pattern)
if ($match.Success) {
[pscustomobject]@{
Date = $match.Groups['Date'].Value
Notes = $match.Groups['Notes'].Value.Trim()
}
}
}
$manifest = Import-PowerShellDataFile -LiteralPath $ManifestPath
$moduleVersion = "$($manifest.ModuleVersion)"
if ($moduleVersion -notmatch '^\d+\.\d+\.\d+$') {
throw "The module version '$moduleVersion' must have three parts, such as 5.0.0."
}
$prerelease = "$($manifest.PrivateData.PSData.Prerelease)"
if ($prerelease -and $prerelease -notmatch '^[A-Za-z][0-9A-Za-z-]*$') {
throw ("The prerelease label '$prerelease' isn't valid in the PowerShell Gallery. Use letters, digits, and " +
'hyphens only, starting with a letter, such as rc1.')
}
$changelog = (Get-Content -LiteralPath $ChangelogPath -Raw -Encoding UTF8) -replace '\r\n', "`n"
$versionSection = Get-ChangelogSection -Changelog $changelog -Name $moduleVersion
if ($prerelease) {
if ($versionSection) {
throw ("CHANGELOG.md already has a section for $moduleVersion, so $moduleVersion is released. A prerelease " +
'needs a higher module version.')
}
$section = Get-ChangelogSection -Changelog $changelog -Name 'Unreleased'
if (-not $section -or -not $section.Notes) {
throw "The [Unreleased] section of CHANGELOG.md is empty. It holds the notes of $moduleVersion-$prerelease."
}
$date = $null
} else {
if (-not $versionSection) {
throw ("CHANGELOG.md has no section for $moduleVersion. Rename the [Unreleased] section to " +
"[$moduleVersion] and add the release date.")
}
if ($versionSection.Date -notmatch '^\d{4}-\d{2}-\d{2}$') {
throw "The section of $moduleVersion in CHANGELOG.md has no release date in the format yyyy-MM-dd."
}
if (-not $versionSection.Notes) {
throw "The section of $moduleVersion in CHANGELOG.md is empty."
}
$section = $versionSection
$date = [datetime]::ParseExact($versionSection.Date, 'yyyy-MM-dd', [cultureinfo]::InvariantCulture)
}
[pscustomobject]@{
Version = if ($prerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion }
ModuleVersion = $moduleVersion
Prerelease = $prerelease
IsPrerelease = [bool] $prerelease
Date = $date
Notes = $section.Notes
}

148
.github/scripts/New-ModulePackage.ps1

@ -0,0 +1,148 @@
<#
.SYNOPSIS
Builds the packages of a release from the build output of the module.
.DESCRIPTION
Copies the files that the FileList of the module manifest names from BuildPath into the folder NTFSSecurity in
DestinationPath, so that the packages contain no debug symbols or other build output, and checks the copy with
Test-ModuleManifest. Then it creates the NuGet package for the PowerShell Gallery with Compress-PSResource, named
after the version and the prerelease label, and adds the command tags that the PowerShell Gallery uses to list the
cmdlets of the module. Last, it creates NTFSSecurity.zip, which contains the folder NTFSSecurity, for the GitHub
release.
The script needs Compress-PSResource from Microsoft.PowerShell.PSResourceGet, which comes with PowerShell 7.4 and
later.
.PARAMETER BuildPath
Specifies the build output folder, such as NTFSSecurity\bin\Release.
.PARAMETER DestinationPath
Specifies the folder for the module folder and the packages. The script replaces the module folder and the
packages that an earlier run created there.
.EXAMPLE
.\.github\scripts\New-ModulePackage.ps1 -BuildPath .\NTFSSecurity\bin\Release -DestinationPath .\out
Creates the folder .\out\NTFSSecurity and the files .\out\NTFSSecurity.<version>.nupkg and .\out\NTFSSecurity.zip.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param (
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
[string]
$BuildPath,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]
$DestinationPath
)
$ErrorActionPreference = 'Stop'
$moduleName = 'NTFSSecurity'
if (-not (Get-Command -Name Compress-PSResource -ErrorAction SilentlyContinue)) {
throw 'Compress-PSResource was not found. Run the script in PowerShell 7.4 or later, which includes PSResourceGet.'
}
$buildRoot = (Resolve-Path -LiteralPath $BuildPath).ProviderPath
$manifestPath = Join-Path -Path $buildRoot -ChildPath "$moduleName.psd1"
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
throw "The build output in '$buildRoot' has no module manifest $moduleName.psd1."
}
$manifest = Import-PowerShellDataFile -LiteralPath $manifestPath
$version = "$($manifest.ModuleVersion)"
if ($manifest.PrivateData.PSData.Prerelease) {
$version = '{0}-{1}' -f $version, $manifest.PrivateData.PSData.Prerelease
}
$missingFiles = @($manifest.FileList | Where-Object -FilterScript {
-not (Test-Path -LiteralPath (Join-Path -Path $buildRoot -ChildPath $_) -PathType Leaf)
})
if ($missingFiles) {
throw "The build output in '$buildRoot' lacks these files of the FileList: $($missingFiles -join ', ')."
}
$destinationRoot = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DestinationPath)
$modulePath = Join-Path -Path $destinationRoot -ChildPath $moduleName
$packagePath = Join-Path -Path $destinationRoot -ChildPath "$moduleName.$version.nupkg"
$zipPath = Join-Path -Path $destinationRoot -ChildPath "$moduleName.zip"
foreach ($path in $modulePath, $packagePath, $zipPath) {
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Recurse -Force
}
}
New-Item -ItemType Directory -Path $modulePath -Force | Out-Null
foreach ($file in $manifest.FileList) {
$target = Join-Path -Path $modulePath -ChildPath $file
$targetFolder = Split-Path -Path $target -Parent
if (-not (Test-Path -LiteralPath $targetFolder)) {
New-Item -ItemType Directory -Path $targetFolder | Out-Null
}
Copy-Item -LiteralPath (Join-Path -Path $buildRoot -ChildPath $file) -Destination $target
}
$testParameters = @{
Path = Join-Path -Path $modulePath -ChildPath "$moduleName.psd1"
ErrorAction = 'Stop'
WarningVariable = 'manifestWarnings'
WarningAction = 'SilentlyContinue'
}
$null = Test-ModuleManifest @testParameters
if ($manifestWarnings) {
throw "Test-ModuleManifest reported warnings for the module in '$modulePath': $($manifestWarnings -join ' ')"
}
Compress-PSResource -Path $modulePath -DestinationPath $destinationRoot
if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) {
throw "Compress-PSResource didn't create the package '$packagePath'."
}
<#
PSResourceGet doesn't add the command tags that PowerShellGet 2 added when it published 4.2.6. The PowerShell
Gallery lists the cmdlets of a module from these tags, and Find-Command searches them, so add the missing ones to
the nuspec in the package.
#>
$commandTags = @('PSIncludes_Cmdlet') + @($manifest.CmdletsToExport | Sort-Object -Unique |
ForEach-Object -Process { "PSCmdlet_$_"; "PSCommand_$_" })
$archive = [IO.Compression.ZipFile]::Open($packagePath, [IO.Compression.ZipArchiveMode]::Update)
try {
$stream = $archive.GetEntry("$moduleName.nuspec").Open()
try {
$nuspec = New-Object -TypeName 'System.Xml.XmlDocument'
$nuspec.Load($stream)
$tagsNode = $nuspec.SelectSingleNode("/*[local-name()='package']/*[local-name()='metadata']/*[local-name()='tags']")
if (-not $tagsNode) {
throw "The nuspec in the package '$packagePath' has no tags element."
}
$tags = @($tagsNode.InnerText -split '\s+' | Where-Object -FilterScript { $_ })
$tagsNode.InnerText = ($tags + @($commandTags | Where-Object -FilterScript { $_ -notin $tags })) -join ' '
$stream.SetLength(0)
$settings = New-Object -TypeName 'System.Xml.XmlWriterSettings'
$settings.Encoding = New-Object -TypeName 'System.Text.UTF8Encoding' -ArgumentList $false
$settings.Indent = $true
$writer = [Xml.XmlWriter]::Create($stream, $settings)
try {
$nuspec.Save($writer)
} finally {
$writer.Dispose()
}
} finally {
$stream.Dispose()
}
} finally {
$archive.Dispose()
}
[IO.Compression.ZipFile]::CreateFromDirectory($modulePath, $zipPath, [IO.Compression.CompressionLevel]::Optimal, $true)
[pscustomobject]@{
Version = $version
ModulePath = $modulePath
PackagePath = $packagePath
ZipPath = $zipPath
}

124
.github/workflows/ci.yml

@ -1,7 +1,10 @@
# Builds the NTFSSecurity module from source, checks that the cmdlet
# documentation in Docs/Cmdlets and the help file generated from it match the
# cmdlets of that build, runs the Pester tests in Windows PowerShell 5.1 and
# PowerShell 7, and publishes Docs to the GitHub wiki from master.
# PowerShell 7, and builds the packages. From master, it publishes Docs to the
# GitHub wiki. For a version tag, such as 5.0.0 or 5.0.0-rc1, it publishes the
# package to the PowerShell Gallery and creates the GitHub release; see
# Docs/Contributing/05-Releasing.md.
name: CI
on:
@ -9,6 +12,9 @@ on:
push:
branches:
- master
tags:
- '[0-9]+.[0-9]+.[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
permissions:
@ -123,13 +129,28 @@ jobs:
path: TestResults/
if-no-files-found: ignore
# Only the files of the FileList, without debug symbols or other build output
- name: Build the packages
shell: pwsh
run: ./.github/scripts/New-ModulePackage.ps1 -BuildPath ./NTFSSecurity/bin/Release -DestinationPath ./out | Format-List
- name: Upload the packages
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages
path: |
out/*.nupkg
out/NTFSSecurity.zip
if-no-files-found: error
wiki:
name: Wiki
needs: build
if: github.ref_type != 'tag'
runs-on: ubuntu-latest
timeout-minutes: 10
# Only this job can write: it publishes the wiki from master. On pull
# requests, it shows the pages that would change.
# This job can write: it publishes the wiki from master. On pull requests,
# it shows the pages that would change.
permissions:
contents: write
steps:
@ -179,3 +200,100 @@ jobs:
if ($LASTEXITCODE -ne 0) {
throw "Publishing the wiki failed with exit code $LASTEXITCODE."
}
release:
name: Release
needs: build
if: github.ref_type == 'tag'
runs-on: windows-2025
timeout-minutes: 15
# The API key of the PowerShell Gallery is a secret of this environment, so
# only this job can read it. The job can write to create the GitHub release.
environment: powershell-gallery
permissions:
contents: write
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Download the packages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: packages
path: out
- name: Check the release
id: release
shell: pwsh
run: |
$release = ./.github/scripts/Get-ReleaseInfo.ps1 -ManifestPath ./NTFSSecurity/NTFSSecurity.psd1 -ChangelogPath ./CHANGELOG.md
if ($env:GITHUB_REF_NAME -cne $release.Version) {
throw "The tag $env:GITHUB_REF_NAME doesn't match the version $($release.Version) in the module manifest."
}
git merge-base --is-ancestor $env:GITHUB_SHA origin/master
if ($LASTEXITCODE -ne 0) {
throw "The tag $env:GITHUB_REF_NAME doesn't point to a commit on master."
}
$package = "out/NTFSSecurity.$($release.Version).nupkg"
if (-not (Test-Path -LiteralPath $package) -or -not (Test-Path -LiteralPath 'out/NTFSSecurity.zip')) {
throw "The packages of $($release.Version) are missing."
}
$today = (Get-Date).ToUniversalTime().Date
if (-not $release.IsPrerelease -and $release.Date -ne $today) {
"::warning::CHANGELOG.md dates $($release.Version) $($release.Date.ToString('yyyy-MM-dd')), but it is released on $($today.ToString('yyyy-MM-dd'))."
}
$notes = Join-Path -Path $env:RUNNER_TEMP -ChildPath 'release-notes.md'
Set-Content -LiteralPath $notes -Value $release.Notes -Encoding utf8NoBOM
Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value @(
"version=$($release.Version)"
"prerelease=$($release.IsPrerelease.ToString().ToLowerInvariant())"
"package=$package"
"notes=$notes"
)
# Publishes the package that the build job built and tested. A rerun skips
# a version that the PowerShell Gallery already has.
- name: Publish to the PowerShell Gallery
shell: pwsh
env:
PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
RELEASE_VERSION: ${{ steps.release.outputs.version }}
RELEASE_PACKAGE: ${{ steps.release.outputs.package }}
run: |
if (-not $env:PSGALLERY_API_KEY) {
throw 'The secret PSGALLERY_API_KEY of the environment powershell-gallery is not set.'
}
$published = Find-PSResource -Name NTFSSecurity -Version $env:RELEASE_VERSION -Prerelease -Repository PSGallery -ErrorAction SilentlyContinue
if ($published) {
"NTFSSecurity $env:RELEASE_VERSION is already in the PowerShell Gallery."
exit 0
}
Publish-PSResource -NupkgPath $env:RELEASE_PACKAGE -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY
- name: Create the GitHub release
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ steps.release.outputs.version }}
RELEASE_PRERELEASE: ${{ steps.release.outputs.prerelease }}
RELEASE_NOTES: ${{ steps.release.outputs.notes }}
run: |
gh release view $env:RELEASE_VERSION --repo $env:GITHUB_REPOSITORY *> $null
if ($LASTEXITCODE -eq 0) {
"The GitHub release $env:RELEASE_VERSION exists."
exit 0
}
$arguments = @(
'release', 'create', $env:RELEASE_VERSION, 'out/NTFSSecurity.zip', '--repo', $env:GITHUB_REPOSITORY,
'--title', $env:RELEASE_VERSION, '--notes-file', $env:RELEASE_NOTES, '--verify-tag'
)
if ($env:RELEASE_PRERELEASE -eq 'true') {
$arguments += '--prerelease'
}
gh @arguments
if ($LASTEXITCODE -ne 0) {
throw "Creating the GitHub release failed with exit code $LASTEXITCODE."
}

19
CHANGELOG.md

@ -13,14 +13,7 @@ The format is based on
- Publish the documentation in the
[wiki](https://github.com/raandree/NTFSSecurity/wiki), generated from the
`Docs` folder after every change, with a sidebar that lists all cmdlets
### Changed
- Complete the version history with the release dates from the PowerShell
Gallery, notes for 4.2.2, detailed notes for 4.2.4, and separate notes for
4.2.5 and 4.2.6
## [5.0.0] - 2026-10-04
- Link the release notes in the PowerShell Gallery to this changelog
### Changed
@ -32,12 +25,17 @@ The format is based on
- Rename the `-PassThur` parameter of `Remove-Item2` to `-PassThru`;
`-PassThur` still works as an alias
([#64](https://github.com/raandree/NTFSSecurity/pull/64))
- Publish the module as a Release build that contains only the module
files; 4.2.6 was a Debug build with debug symbols and a copy of
`System.Management.Automation.dll`
- Document every cmdlet with synopsis, description, parameters, examples,
inputs, outputs, and notes, checked against the source code
- Rewrite the home, concepts, examples, and contributor pages to match the
current cmdlets, including module settings, privileges, and long paths
- Move the version history and the installation instructions from the wiki
into the documentation, and add the missing notes for 4.2.5 and 4.2.6
into the documentation, and complete the version history with the release
dates from the PowerShell Gallery, the missing notes for 4.2.2, 4.2.5, and
4.2.6, and detailed notes for 4.2.4
### Deprecated
@ -55,5 +53,4 @@ The format is based on
entries from the cmdlets that the module manifest exports and the
PowerShell Gallery lists
[Unreleased]: https://github.com/raandree/NTFSSecurity/compare/5.0.0...HEAD
[5.0.0]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...5.0.0
[Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD

3
Docs/Contributing.md

@ -10,5 +10,8 @@ documentation:
3. [Style guide](Contributing/03-Style-Guide.md)
4. [Markdown and platyPS specifics](Contributing/04-Markdown-Specifics.md)
Maintainers release new versions as described in
[Release a new version](Contributing/05-Releasing.md).
This guide is adapted from the contributor guide of the
[PowerShell documentation](https://github.com/MicrosoftDocs/PowerShell-Docs).

2
Docs/Contributing/02-Writing.md

@ -18,7 +18,7 @@ This page explains how the documentation is organized and how to change it.
| `Docs/Contributing.md`, `Docs/Contributing/*.md` | This contributor guide |
| `README.md` | Front page of the GitHub repository |
| `CHANGELOG.md` | Changes since 4.2.6 |
| `.github/workflows/ci.yml` | CI build: documentation checks, tests, and wiki publishing |
| `.github/workflows/ci.yml` | CI build: documentation checks, tests, packages, wiki publishing, and releases |
| `.github/scripts/Export-WikiContent.ps1` | Converts `Docs` into the pages of the wiki |
When you add a page, link to it from `Docs/README.md` or from a related page,

110
Docs/Contributing/05-Releasing.md

@ -0,0 +1,110 @@
# Release a new version
This page explains how a maintainer releases a new version of NTFSSecurity.
The CI workflow does the work: when you push a version tag, it builds and
tests the module, publishes the package to the PowerShell Gallery, and creates
the GitHub release with `NTFSSecurity.zip`.
## Prepare the repository once
1. In the PowerShell Gallery, [create an API key][api-key] for the account
that owns NTFSSecurity. Select the scope **Push new versions of existing
packages**, enter `NTFSSecurity` as the glob pattern, and choose an
expiration.
2. In the settings of the repository on GitHub, under **Environments**,
create the environment `powershell-gallery`. To approve every release
before it's published, add yourself as a required reviewer. Under
**Deployment branches and tags**, allow only tags such as
`[0-9]*.[0-9]*.[0-9]*`.
3. Add the API key as the secret `PSGALLERY_API_KEY` of the environment.
The command asks for the key:
```powershell
gh secret set PSGALLERY_API_KEY --env powershell-gallery --repo raandree/NTFSSecurity
```
Renew the API key before it expires, and update the secret.
## Versions
- `ModuleVersion` in `NTFSSecurity\NTFSSecurity.psd1` and the
`AssemblyVersion` and `AssemblyFileVersion` of the `NTFSSecurity`,
`Security2`, and `PrivilegeControl` projects carry the same version. The
tests in `Tests\Manifest.Tests.ps1` check this.
- A prerelease, such as `5.0.0-rc1`, has its label in `Prerelease` under
`PrivateData.PSData` of the module manifest. Its release notes are the
`[Unreleased]` section of `CHANGELOG.md`. The PowerShell Gallery installs a
prerelease only when you add `-AllowPrerelease`.
- A release, such as `5.0.0`, has no `Prerelease` value. Its release notes
are the section `## [5.0.0] - <date>` of `CHANGELOG.md`.
The tests in `Tests\Release.Tests.ps1` check that `CHANGELOG.md` has the
release notes for the version of the module manifest.
## Publish a prerelease
1. Set the version and the `Prerelease` label, such as `rc1`, and make sure
that the `[Unreleased]` section of `CHANGELOG.md` describes the changes.
Merge the change into `master`.
2. Tag the commit on `master` with the version and push the tag:
```powershell
git switch master
git pull
git tag 5.0.0-rc1
git push origin 5.0.0-rc1
```
3. Watch the CI run of the tag. The **Release** job checks that the tag
matches the version in the module manifest and points to a commit on
`master`. Then it publishes the package and creates a GitHub prerelease.
If the environment requires a reviewer, approve the deployment.
4. Install the prerelease in a test environment and test it:
```powershell
Install-Module -Name NTFSSecurity -AllowPrerelease -Scope CurrentUser
```
For another prerelease, increase the label, such as `rc2`. The PowerShell
Gallery compares labels as text, so `rc10` sorts before `rc2`.
## Publish a release
1. Remove the `Prerelease` value from the module manifest.
2. In `CHANGELOG.md`, rename `## [Unreleased]` to the version with the
release date, such as `## [5.0.0] - 2026-10-31`, add an empty
`## [Unreleased]` section above it, and update the links at the end of the
file:
```markdown
[Unreleased]: https://github.com/raandree/NTFSSecurity/compare/5.0.0...HEAD
[5.0.0]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...5.0.0
```
3. Merge the change into `master`. Then tag the commit with the version, such
as `5.0.0`, and push the tag, as for a prerelease. The **Release** job
warns if the date in `CHANGELOG.md` isn't the day of the release.
## If a release fails
The **Release** job skips what's already done: a version that the PowerShell
Gallery already has, and a GitHub release that already exists. If the
failure doesn't need a change in the repository, fix the cause and rerun the
failed job.
If the fix needs a change in the repository and the PowerShell Gallery
doesn't have the version yet, delete the tag, merge the fix, and tag the new
commit:
```powershell
git push origin --delete 5.0.0-rc1
git tag --delete 5.0.0-rc1
```
The PowerShell Gallery never accepts the same version twice. To replace a
published version, publish the next one, such as `5.0.0-rc2`, and
[unlist][unlist] the faulty version.
<!-- External URLs -->
[api-key]: https://learn.microsoft.com/powershell/gallery/how-to/managing-profile/creating-apikeys
[unlist]: https://learn.microsoft.com/powershell/gallery/how-to/publishing-packages/unlisting-packages

3
Docs/README.md

@ -38,6 +38,9 @@ Install the module from the
Install-Module -Name NTFSSecurity
```
To try a prerelease of the next version, such as `5.0.0-rc1`, add
`-AllowPrerelease`. A prerelease is for testing; don't use it in production.
You can also install a release without the PowerShell Gallery. Download
`NTFSSecurity.zip` from the
[releases page](https://github.com/raandree/NTFSSecurity/releases) on GitHub

3
NTFSSecurity/NTFSSecurity.psd1

@ -100,6 +100,9 @@
Tags = @('AccessControl', 'ACL', 'DirectorySecurity', 'FileSecurity', 'FileSystem', 'FileSystemSecurity', 'NTFS', 'Module', 'AccessRights')
LicenseUri = 'https://github.com/raandree/NTFSSecurity/blob/master/LICENSE'
ProjectUri = 'https://github.com/raandree/NTFSSecurity'
ReleaseNotes = 'https://github.com/raandree/NTFSSecurity/blob/master/CHANGELOG.md'
# Remove the prerelease label for the final release, see Docs/Contributing/05-Releasing.md
Prerelease = 'rc1'
}
}
}

10
Tests/Manifest.Tests.ps1

@ -1,6 +1,7 @@
<#
Tests the module manifest of the module built in NTFSSecurity\bin\Release: it passes Test-ModuleManifest,
exports exactly the cmdlets of the module, and carries the same version as the assemblies and CHANGELOG.md.
exports exactly the cmdlets of the module, and carries the same version as the assemblies. Release.Tests.ps1
checks that CHANGELOG.md describes that version.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
@ -62,12 +63,5 @@ Describe 'Module manifest of NTFSSecurity' {
$assemblyVersion.ToString(3) | Should -BeExactly $manifest.ModuleVersion
$fileVersion.ToString(3) | Should -BeExactly $manifest.ModuleVersion
}
It 'Should describe the module version in the latest section of CHANGELOG.md' {
$changelog = Get-Content -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\CHANGELOG.md') -Raw
$latestVersion = [regex]::Match($changelog, '(?m)^## \[(?<Version>\d+\.\d+\.\d+)\]').Groups['Version'].Value
$latestVersion | Should -BeExactly $manifest.ModuleVersion
}
}
}

255
Tests/Release.Tests.ps1

@ -0,0 +1,255 @@
<#
Tests the release scripts in .github\scripts: Get-ReleaseInfo.ps1, which reads the version of the module and its
release notes, and New-ModulePackage.ps1, which builds the packages from the module built in
NTFSSecurity\bin\Release.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
)]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'The test helpers write only to TestDrive.'
)]
param ()
BeforeDiscovery {
# Compress-PSResource is part of Microsoft.PowerShell.PSResourceGet, which comes with PowerShell 7.4 and later.
$canPackage = [bool](Get-Command -Name Compress-PSResource -ErrorAction SilentlyContinue)
}
Describe 'Get-ReleaseInfo.ps1' {
BeforeAll {
$scriptPath = Join-Path -Path $PSScriptRoot -ChildPath '..\.github\scripts\Get-ReleaseInfo.ps1'
$manifestPath = Join-Path -Path $TestDrive -ChildPath 'Module.psd1'
function Set-TestManifest {
param (
[Parameter(Mandatory)]
[string]
$Version,
[Parameter()]
[string]
$Prerelease
)
$psData = if ($Prerelease) { "Prerelease = '$Prerelease'" } else { '' }
Set-Content -LiteralPath $manifestPath -Value "@{ ModuleVersion = '$Version'; PrivateData = @{ PSData = @{ $psData } } }"
}
$changelogPath = Join-Path -Path $TestDrive -ChildPath 'CHANGELOG.md'
Set-Content -LiteralPath $changelogPath -Value @'
# Changelog
## [Unreleased]
### Fixed
- Fix a thing
## [2.0.0] - 2026-01-02
### Changed
- Change a thing
## [1.0.0]
### Added
- Add a thing
[Unreleased]: https://example.com/compare/2.0.0...HEAD
[2.0.0]: https://example.com/compare/1.0.0...2.0.0
'@
$releasedChangelogPath = Join-Path -Path $TestDrive -ChildPath 'CHANGELOG-released.md'
Set-Content -LiteralPath $releasedChangelogPath -Value @'
# Changelog
## [Unreleased]
## [2.0.0] - 2026-01-02
### Changed
- Change a thing
[Unreleased]: https://example.com/compare/2.0.0...HEAD
[2.0.0]: https://example.com/compare/1.0.0...2.0.0
'@
}
Context 'When the module manifest has no prerelease label' {
It 'Should return the version, the release date, and the notes of its section' {
Set-TestManifest -Version '2.0.0'
$release = & $scriptPath -ManifestPath $manifestPath -ChangelogPath $changelogPath
$release.Version | Should -BeExactly '2.0.0'
$release.IsPrerelease | Should -BeFalse
$release.Date | Should -Be ([datetime] '2026-01-02')
$release.Notes | Should -BeExactly "### Changed`n`n- Change a thing"
}
It 'Should not include the link definitions after the last section in the notes' {
Set-TestManifest -Version '2.0.0'
$release = & $scriptPath -ManifestPath $manifestPath -ChangelogPath $releasedChangelogPath
$release.Notes | Should -BeExactly "### Changed`n`n- Change a thing"
}
It 'Should fail if the section of the version has no release date' {
Set-TestManifest -Version '1.0.0'
{ & $scriptPath -ManifestPath $manifestPath -ChangelogPath $changelogPath } | Should -Throw '*1.0.0*date*'
}
It 'Should fail if CHANGELOG.md has no section for the version' {
Set-TestManifest -Version '3.0.0'
{ & $scriptPath -ManifestPath $manifestPath -ChangelogPath $changelogPath } | Should -Throw '*3.0.0*'
}
}
Context 'When the module manifest has a prerelease label' {
It 'Should return the prerelease version and the notes of the Unreleased section' {
Set-TestManifest -Version '3.0.0' -Prerelease 'rc1'
$release = & $scriptPath -ManifestPath $manifestPath -ChangelogPath $changelogPath
$release.Version | Should -BeExactly '3.0.0-rc1'
$release.IsPrerelease | Should -BeTrue
$release.Date | Should -BeNullOrEmpty
$release.Notes | Should -BeExactly "### Fixed`n`n- Fix a thing"
}
It 'Should fail if the Unreleased section is empty' {
Set-TestManifest -Version '3.0.0' -Prerelease 'rc1'
{ & $scriptPath -ManifestPath $manifestPath -ChangelogPath $releasedChangelogPath } |
Should -Throw '*Unreleased*'
}
It 'Should fail if CHANGELOG.md already has a section for the version' {
Set-TestManifest -Version '2.0.0' -Prerelease 'rc1'
{ & $scriptPath -ManifestPath $manifestPath -ChangelogPath $changelogPath } | Should -Throw '*2.0.0*'
}
It 'Should fail if the label is not valid in the PowerShell Gallery' {
Set-TestManifest -Version '3.0.0' -Prerelease 'rc.1'
{ & $scriptPath -ManifestPath $manifestPath -ChangelogPath $changelogPath } | Should -Throw '*rc.1*'
}
}
Context 'When it reads the module manifest and CHANGELOG.md of the repository' {
It 'Should return release notes for the version of the module manifest' {
$repositoryPath = Join-Path -Path $PSScriptRoot -ChildPath '..'
$sourceManifestPath = Join-Path -Path $repositoryPath -ChildPath 'NTFSSecurity\NTFSSecurity.psd1'
$manifest = Import-PowerShellDataFile -LiteralPath $sourceManifestPath
$release = & $scriptPath -ManifestPath $sourceManifestPath -ChangelogPath (Join-Path -Path $repositoryPath -ChildPath 'CHANGELOG.md')
$release.Version | Should -BeLike "$($manifest.ModuleVersion)*"
$release.Notes | Should -Not -BeNullOrEmpty
$release.Notes | Should -Not -Match '(?m)^\[[^\]]+\]: '
}
}
}
Describe 'New-ModulePackage.ps1' -Skip:(-not $canPackage) {
BeforeAll {
$scriptPath = Join-Path -Path $PSScriptRoot -ChildPath '..\.github\scripts\New-ModulePackage.ps1'
$buildPath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release'
$manifest = Import-PowerShellDataFile -LiteralPath (Join-Path -Path $buildPath -ChildPath 'NTFSSecurity.psd1')
$version = $manifest.ModuleVersion
if ($manifest.PrivateData.PSData.Prerelease) {
$version = '{0}-{1}' -f $version, $manifest.PrivateData.PSData.Prerelease
}
$expectedFiles = @($manifest.FileList | ForEach-Object -Process { $_ -replace '\\', '/' } | Sort-Object)
function Get-ZipEntryName {
param (
[Parameter(Mandatory)]
[string]
$Path
)
$zip = [IO.Compression.ZipFile]::OpenRead($Path)
try {
$zip.Entries | Where-Object -Property Name | ForEach-Object -Process { $_.FullName }
} finally {
$zip.Dispose()
}
}
$package = & $scriptPath -BuildPath $buildPath -DestinationPath (Join-Path -Path $TestDrive -ChildPath 'out')
}
It 'Should copy exactly the files of the FileList into the module folder' {
$files = Get-ChildItem -LiteralPath $package.ModulePath -Recurse -File |
ForEach-Object -Process { $_.FullName.Substring($package.ModulePath.Length + 1) -replace '\\', '/' }
($files | Sort-Object) -join ', ' | Should -BeExactly ($expectedFiles -join ', ')
}
It 'Should name the NuGet package after the version, including the prerelease label' {
Split-Path -Path $package.PackagePath -Leaf | Should -BeExactly "NTFSSecurity.$version.nupkg"
$package.Version | Should -BeExactly $version
}
It 'Should put exactly the module files into the NuGet package' {
$entries = Get-ZipEntryName -Path $package.PackagePath |
Where-Object -FilterScript { $_ -notmatch '^(_rels/|package/|\[Content_Types\]\.xml$|NTFSSecurity\.nuspec$)' }
($entries | Sort-Object) -join ', ' | Should -BeExactly ($expectedFiles -join ', ')
}
It 'Should set the version and the link to the release notes in the NuGet package' {
$zip = [IO.Compression.ZipFile]::OpenRead($package.PackagePath)
try {
$reader = New-Object -TypeName 'System.IO.StreamReader' -ArgumentList $zip.GetEntry('NTFSSecurity.nuspec').Open()
$nuspec = [xml] $reader.ReadToEnd()
$reader.Dispose()
} finally {
$zip.Dispose()
}
$nuspec.package.metadata.version | Should -BeExactly $version
$nuspec.package.metadata.releaseNotes | Should -Match 'https://github\.com/raandree/NTFSSecurity/blob/master/CHANGELOG\.md'
}
It 'Should tag the NuGet package with its cmdlets, which the PowerShell Gallery lists and Find-Command searches' {
$zip = [IO.Compression.ZipFile]::OpenRead($package.PackagePath)
try {
$reader = New-Object -TypeName 'System.IO.StreamReader' -ArgumentList $zip.GetEntry('NTFSSecurity.nuspec').Open()
$tags = ([xml] $reader.ReadToEnd()).package.metadata.tags -split '\s+'
$reader.Dispose()
} finally {
$zip.Dispose()
}
$expectedTags = @('PSModule', 'PSIncludes_Cmdlet') + $manifest.PrivateData.PSData.Tags +
@($manifest.CmdletsToExport | ForEach-Object -Process { "PSCmdlet_$_"; "PSCommand_$_" })
$expectedTags | Where-Object -FilterScript { $_ -notin $tags } | Should -BeNullOrEmpty
$tags | Group-Object | Where-Object -Property Count -GT -Value 1 | ForEach-Object -Process { $_.Name } |
Should -BeNullOrEmpty
}
It 'Should put the module folder into NTFSSecurity.zip' {
$entries = Get-ZipEntryName -Path $package.ZipPath | Sort-Object
$entries -join ', ' | Should -BeExactly (($expectedFiles | ForEach-Object -Process { "NTFSSecurity/$_" }) -join ', ')
}
It 'Should fail if a file of the FileList is missing from the build output' {
$incompletePath = Join-Path -Path $TestDrive -ChildPath 'incomplete'
Copy-Item -LiteralPath $buildPath -Destination $incompletePath -Recurse
Remove-Item -LiteralPath (Join-Path -Path $incompletePath -ChildPath 'en-US\NTFSSecurity.dll-Help.xml')
{ & $scriptPath -BuildPath $incompletePath -DestinationPath (Join-Path -Path $TestDrive -ChildPath 'out-incomplete') } |
Should -Throw '*NTFSSecurity.dll-Help.xml*'
}
}
Loading…
Cancel
Save