diff --git a/.github/scripts/Get-ReleaseInfo.ps1 b/.github/scripts/Get-ReleaseInfo.ps1 new file mode 100644 index 0000000..ea74c7f --- /dev/null +++ b/.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 "## [] - ". 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]+(?\S+))?[ \t]*$(?.*?)(?=^## |^\[[^\]]+\]:[ \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 +} diff --git a/.github/scripts/New-ModulePackage.ps1 b/.github/scripts/New-ModulePackage.ps1 new file mode 100644 index 0000000..7e133ca --- /dev/null +++ b/.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..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 +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ef29be..ee5e7d6 100644 --- a/.github/workflows/ci.yml +++ b/.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." + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3451647..085bcb2 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Docs/Contributing.md b/Docs/Contributing.md index 016690e..9ca9dff 100644 --- a/Docs/Contributing.md +++ b/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). diff --git a/Docs/Contributing/02-Writing.md b/Docs/Contributing/02-Writing.md index 4ff56b7..98c78a2 100644 --- a/Docs/Contributing/02-Writing.md +++ b/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, diff --git a/Docs/Contributing/05-Releasing.md b/Docs/Contributing/05-Releasing.md new file mode 100644 index 0000000..713dc55 --- /dev/null +++ b/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] - ` 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. + + +[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 diff --git a/Docs/README.md b/Docs/README.md index f392f97..8d6bcff 100644 --- a/Docs/README.md +++ b/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 diff --git a/NTFSSecurity/NTFSSecurity.psd1 b/NTFSSecurity/NTFSSecurity.psd1 index f9ce836..394bfad 100644 --- a/NTFSSecurity/NTFSSecurity.psd1 +++ b/NTFSSecurity/NTFSSecurity.psd1 @@ -97,9 +97,12 @@ IdentifyHardLinks = $true PSData = @{ - 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' + 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' } } } \ No newline at end of file diff --git a/Tests/Manifest.Tests.ps1 b/Tests/Manifest.Tests.ps1 index 7f33576..087e8e4 100644 --- a/Tests/Manifest.Tests.ps1 +++ b/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)^## \[(?\d+\.\d+\.\d+)\]').Groups['Version'].Value - - $latestVersion | Should -BeExactly $manifest.ModuleVersion - } } } diff --git a/Tests/Release.Tests.ps1 b/Tests/Release.Tests.ps1 new file mode 100644 index 0000000..b7edfd8 --- /dev/null +++ b/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*' + } +}