From 841c6d5cdd1f763bd53a4d38525a36b59635c5f2 Mon Sep 17 00:00:00 2001 From: Shawn Fagan Date: Thu, 16 Jul 2026 21:35:41 -0400 Subject: [PATCH 1/9] feat: implement automated versioning and signing workflow for production releases --- .github/workflows/build-Production.yml | 144 ++------------------ .github/workflows/develop-prerelease.yml | 112 ++++++++++++++++ .github/workflows/prepare-main-pr.yml | 92 +++++++++++++ README.md | 6 + build/Sign-Module.ps1 | 159 +++++++++++++++++++++++ build/Update-ModuleVersion.ps1 | 42 +++++- testResults.xml | 20 +++ 7 files changed, 436 insertions(+), 139 deletions(-) create mode 100644 .github/workflows/develop-prerelease.yml create mode 100644 .github/workflows/prepare-main-pr.yml create mode 100644 build/Sign-Module.ps1 create mode 100644 testResults.xml diff --git a/.github/workflows/build-Production.yml b/.github/workflows/build-Production.yml index fba4ef9..880edf6 100644 --- a/.github/workflows/build-Production.yml +++ b/.github/workflows/build-Production.yml @@ -35,6 +35,9 @@ jobs: if ([string]::IsNullOrWhiteSpace($moduleVersion)) { throw 'ModuleVersion was not found in M365.Toolkit.psd1.' } + if (-not [string]::IsNullOrWhiteSpace([string]$manifest.PrivateData.PSData.Prerelease)) { + throw 'Production releases cannot contain a prerelease label.' + } $releaseTag = "v$moduleVersion" @@ -72,142 +75,11 @@ jobs: if ([string]::IsNullOrWhiteSpace($env:CODESIGNINGPFXPASSWORD)) { throw 'Secret CODESIGNINGPFXPASSWORD is missing.' } - $pfxCertificates = [Security.Cryptography.X509Certificates.X509Certificate2Collection]::new() - $pfxCertificates = [Security.Cryptography.X509Certificates.X509Certificate2Collection]::new() - $certificate = $null - $pfxBytes = $null - - try { - Write-Host "[$(Get-Date -Format o)] Decoding the code-signing PFX." - $pfxBytes = [Convert]::FromBase64String($env:CODESIGNINGPFXBASE64) - - Write-Host "[$(Get-Date -Format o)] Loading the code-signing certificate in memory." - $pfxCertificates.Import( - $pfxBytes, - $env:CODESIGNINGPFXPASSWORD, - [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet - ) - - $codeSigningOid = '1.3.6.1.5.5.7.3.3' - $codeSigningCertificates = @( - foreach ($candidate in $pfxCertificates) { - $ekuExtension = $candidate.Extensions | - Where-Object { - $_ -is [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension] - } | - Select-Object -First 1 - $ekuOids = @( - if ($null -ne $ekuExtension) { - $ekuExtension.EnhancedKeyUsages | ForEach-Object Value - } - ) - - Write-Host ( - "Loaded certificate: Subject='{0}', Thumbprint='{1}', HasPrivateKey={2}, EKUs='{3}'" -f - $candidate.Subject, - $candidate.Thumbprint, - $candidate.HasPrivateKey, - ($ekuOids -join ',') - ) - - if ($candidate.HasPrivateKey -and $ekuOids -contains $codeSigningOid) { - $candidate - } - } - ) - $certificate = $codeSigningCertificates | Select-Object -First 1 - - if ($null -eq $certificate) { - throw 'The PFX does not contain a certificate with a private key that is valid for code signing.' - } - - $isSelfSigned = $certificate.Subject -eq $certificate.Issuer - if ($isSelfSigned) { - Write-Warning 'Using a self-signed certificate. Signature trust cannot be established on the runner.' - } - - $now = Get-Date - if ($now -lt $certificate.NotBefore -or $now -gt $certificate.NotAfter) { - throw 'The code-signing certificate is not currently valid.' - } - - $moduleFiles = @( - Get-Item -Path ./M365.Toolkit.psd1, ./M365.Toolkit.psm1 - Get-ChildItem -Path ./src/Private, ./src/Public -Recurse -File | - Where-Object Extension -In '.ps1', '.psm1', '.psd1', '.ps1xml', '.cdxml' - ) - - foreach ($file in $moduleFiles) { - Write-Host "[$(Get-Date -Format o)] Signing $($file.FullName)" - $signingParameters = @{ - FilePath = $file.FullName - Certificate = $certificate - HashAlgorithm = 'SHA256' - IncludeChain = 'All' - } - if (-not $isSelfSigned) { - $signingParameters.TimestampServer = 'https://timestamp.digicert.com' - } - - $signature = Set-AuthenticodeSignature @signingParameters - Write-Host "[$(Get-Date -Format o)] Signature status: $($signature.Status)" - - $acceptableStatus = if ($isSelfSigned) { - $signature.Status -in @( - [System.Management.Automation.SignatureStatus]::Valid, - [System.Management.Automation.SignatureStatus]::UnknownError - ) - } - else { - $signature.Status -eq [System.Management.Automation.SignatureStatus]::Valid - } - - if ( - -not $acceptableStatus -or - $null -eq $signature.SignerCertificate -or - $signature.SignerCertificate.Thumbprint -ne $certificate.Thumbprint - ) { - throw "Failed to sign '$($file.FullName)': $($signature.StatusMessage)" - } - } - - $invalidSignatures = @( - foreach ($file in $moduleFiles) { - $signature = Get-AuthenticodeSignature -FilePath $file.FullName - $acceptableStatus = if ($isSelfSigned) { - $signature.Status -in @( - [System.Management.Automation.SignatureStatus]::Valid, - [System.Management.Automation.SignatureStatus]::UnknownError - ) - } - else { - $signature.Status -eq [System.Management.Automation.SignatureStatus]::Valid - } - - if ( - -not $acceptableStatus -or - $null -eq $signature.SignerCertificate -or - $signature.SignerCertificate.Thumbprint -ne $certificate.Thumbprint - ) { - $signature - } - } - ) - if ($invalidSignatures.Count -gt 0) { - $invalidFiles = $invalidSignatures.Path -join ', ' - throw "Signature validation failed for: $invalidFiles" - } - - Write-Host "Signed and validated $($moduleFiles.Count) module files." - } - finally { - foreach ($pfxCertificate in $pfxCertificates) { - $pfxCertificate.Dispose() - } - if ($null -ne $pfxBytes) { - [Array]::Clear($pfxBytes, 0, $pfxBytes.Length) - } - } + + ./build/Sign-Module.ps1 ` + -ModulePath . ` + -PfxBase64 $env:CODESIGNINGPFXBASE64 ` + -PfxPassword $env:CODESIGNINGPFXPASSWORD - name: Create or update GitHub release if: github.event_name != 'pull_request' diff --git a/.github/workflows/develop-prerelease.yml b/.github/workflows/develop-prerelease.yml new file mode 100644 index 0000000..21067bd --- /dev/null +++ b/.github/workflows/develop-prerelease.yml @@ -0,0 +1,112 @@ +name: Publish Develop Prerelease + +on: + push: + branches: + - develop + paths-ignore: + - M365.Toolkit.psd1 + - docs/** + - .github/workflows/pages.yml + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: develop-prerelease + cancel-in-progress: false + +jobs: + build-sign-publish: + if: github.actor != 'github-actions[bot]' + runs-on: windows-latest + + steps: + - name: Checkout develop + uses: actions/checkout@v7 + with: + ref: develop + + - name: Install dependencies + shell: pwsh + run: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module -Name Pester -MinimumVersion 5.0.0 -Force -Scope CurrentUser + Install-Module -Name PnP.PowerShell -RequiredVersion 3.1.0 -Force -Scope CurrentUser -AllowClobber + + - name: Bump preview version + id: version + shell: pwsh + run: | + $newVersion = ./build/Update-ModuleVersion.ps1 ` + -ManifestPath ./M365.Toolkit.psd1 ` + -Bump Build ` + -Prerelease preview + + "module_version=$newVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "package_version=$newVersion-preview" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Validate and test module + shell: pwsh + run: | + Test-ModuleManifest -Path ./M365.Toolkit.psd1 | Out-Null + Invoke-Pester -Path ./src/tests/M365.Toolkit.Tests.ps1 -CI + + - name: Commit preview version + shell: pwsh + env: + PACKAGE_VERSION: ${{ steps.version.outputs.package_version }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add M365.Toolkit.psd1 + git commit -m "chore: bump preview version to $env:PACKAGE_VERSION [skip ci]" + git push origin HEAD:develop + + - name: Sign module scripts + shell: pwsh + env: + CODESIGNINGPFXBASE64: ${{ secrets.CODESIGNINGPFXBASE64 }} + CODESIGNINGPFXPASSWORD: ${{ secrets.CODESIGNINGPFXPASSWORD }} + run: | + if ([string]::IsNullOrWhiteSpace($env:CODESIGNINGPFXBASE64)) { + throw 'Secret CODESIGNINGPFXBASE64 is missing.' + } + if ([string]::IsNullOrWhiteSpace($env:CODESIGNINGPFXPASSWORD)) { + throw 'Secret CODESIGNINGPFXPASSWORD is missing.' + } + + ./build/Sign-Module.ps1 ` + -ModulePath . ` + -PfxBase64 $env:CODESIGNINGPFXBASE64 ` + -PfxPassword $env:CODESIGNINGPFXPASSWORD + + - name: Publish preview to PowerShell Gallery + shell: pwsh + env: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERYAPIKEY }} + PACKAGE_VERSION: ${{ steps.version.outputs.package_version }} + run: | + if ([string]::IsNullOrWhiteSpace($env:PSGALLERY_API_KEY)) { + throw 'Secret PSGALLERYAPIKEY is missing.' + } + + $existing = Find-Module ` + -Name M365.Toolkit ` + -RequiredVersion $env:PACKAGE_VERSION ` + -AllowPrerelease ` + -Repository PSGallery ` + -ErrorAction SilentlyContinue + + if ($null -ne $existing) { + Write-Host "Skipping publish. M365.Toolkit $env:PACKAGE_VERSION already exists." + exit 0 + } + + Publish-Module ` + -Path . ` + -NuGetApiKey $env:PSGALLERY_API_KEY ` + -Repository PSGallery ` + -Force ` + -ErrorAction Stop diff --git a/.github/workflows/prepare-main-pr.yml b/.github/workflows/prepare-main-pr.yml new file mode 100644 index 0000000..3324e2d --- /dev/null +++ b/.github/workflows/prepare-main-pr.yml @@ -0,0 +1,92 @@ +name: Prepare Main Pull Request Version + +on: + pull_request: + branches: + - main + types: + - opened + - labeled + +permissions: + contents: write + pull-requests: read + +concurrency: + group: main-pr-version-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + update-version: + if: >- + github.actor != 'github-actions[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: windows-latest + + steps: + - name: Checkout pull request branch + uses: actions/checkout@v7 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.ref }} + + - name: Set stable pull request version + id: version + shell: pwsh + env: + EVENT_ACTION: ${{ github.event.action }} + EVENT_LABEL: ${{ github.event.label.name }} + PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} + run: | + $versionPattern = '^v?\d+\.\d+\.\d+(?:\.\d+)?$' + $versionLabels = @( + $env:PR_LABELS_JSON | + ConvertFrom-Json | + Where-Object { $_ -match $versionPattern } + ) + + if ($versionLabels.Count -gt 1) { + throw "The pull request has multiple version labels: $($versionLabels -join ', ')" + } + + if ($env:EVENT_ACTION -eq 'labeled' -and $env:EVENT_LABEL -notmatch $versionPattern) { + 'changed=false' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + exit 0 + } + + if ($versionLabels.Count -eq 1) { + $explicitVersion = $versionLabels[0] -replace '^v', '' + $newVersion = ./build/Update-ModuleVersion.ps1 ` + -ManifestPath ./M365.Toolkit.psd1 ` + -Version $explicitVersion ` + -ClearPrerelease + } + else { + $newVersion = ./build/Update-ModuleVersion.ps1 ` + -ManifestPath ./M365.Toolkit.psd1 ` + -Bump Patch ` + -ClearPrerelease + } + + 'changed=true' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "new_version=$newVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Commit stable version + if: steps.version.outputs.changed == 'true' + shell: pwsh + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + NEW_VERSION: ${{ steps.version.outputs.new_version }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add M365.Toolkit.psd1 + + git diff --cached --quiet + if ($LASTEXITCODE -eq 0) { + Write-Host 'The manifest already has the requested version.' + exit 0 + } + + git commit -m "chore: set module version to v$env:NEW_VERSION" + git push origin "HEAD:$env:HEAD_REF" diff --git a/README.md b/README.md index 302b62c..e35d639 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,9 @@ Convert a PFX to Base64 in PowerShell: The production workflow signs with SHA-256, adds a trusted timestamp for CA-issued certificates, validates every signature, and removes the temporary certificate before publishing to PowerShell Gallery. A self-signed certificate is signed without an external timestamp. The workflow validates the embedded signer and rejects missing, mismatched, or hash-invalid signatures, but it cannot establish certificate trust. Use a publicly trusted code-signing certificate for production distribution. + +## Automated Versioning + +Each non-bot push to `develop` increments the three-part module build version, applies the `preview` prerelease label, and publishes the signed package to PowerShell Gallery. The workflow commits the new version to `develop` without triggering another build. + +When a pull request is opened against `main`, the workflow increments the patch version and removes the prerelease label. Add a label such as `v1.2.3.4` to set that exact version instead. Adding a non-version label does not change the manifest. diff --git a/build/Sign-Module.ps1 b/build/Sign-Module.ps1 new file mode 100644 index 0000000..36b4f45 --- /dev/null +++ b/build/Sign-Module.ps1 @@ -0,0 +1,159 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$ModulePath, + + [Parameter(Mandatory)] + [string]$PfxBase64, + + [Parameter(Mandatory)] + [string]$PfxPassword, + + [Parameter()] + [uri]$TimestampServer = 'https://timestamp.digicert.com' +) + +$ErrorActionPreference = 'Stop' +$moduleRoot = (Resolve-Path -Path $ModulePath).Path +$pfxCertificates = [Security.Cryptography.X509Certificates.X509Certificate2Collection]::new() +$certificate = $null +$pfxBytes = $null + +try { + Write-Host "[$(Get-Date -Format o)] Decoding the code-signing PFX." + $pfxBytes = [Convert]::FromBase64String($PfxBase64) + + Write-Host "[$(Get-Date -Format o)] Loading the code-signing certificate in memory." + $pfxCertificates.Import( + $pfxBytes, + $PfxPassword, + [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + ) + + $codeSigningOid = '1.3.6.1.5.5.7.3.3' + $codeSigningCertificates = @( + foreach ($candidate in $pfxCertificates) { + $ekuExtension = $candidate.Extensions | + Where-Object { + $_ -is [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension] + } | + Select-Object -First 1 + $ekuOids = @( + if ($null -ne $ekuExtension) { + $ekuExtension.EnhancedKeyUsages | ForEach-Object Value + } + ) + + Write-Host ( + "Loaded certificate: Subject='{0}', Thumbprint='{1}', HasPrivateKey={2}, EKUs='{3}'" -f + $candidate.Subject, + $candidate.Thumbprint, + $candidate.HasPrivateKey, + ($ekuOids -join ',') + ) + + if ($candidate.HasPrivateKey -and $ekuOids -contains $codeSigningOid) { + $candidate + } + } + ) + $certificate = $codeSigningCertificates | Select-Object -First 1 + + if ($null -eq $certificate) { + throw 'The PFX does not contain a certificate with a private key that is valid for code signing.' + } + + $isSelfSigned = $certificate.Subject -eq $certificate.Issuer + if ($isSelfSigned) { + Write-Warning 'Using a self-signed certificate. Signature trust cannot be established on the runner.' + } + + $now = Get-Date + if ($now -lt $certificate.NotBefore -or $now -gt $certificate.NotAfter) { + throw 'The code-signing certificate is not currently valid.' + } + + $moduleFiles = @( + Get-Item -Path ( + Join-Path $moduleRoot 'M365.Toolkit.psd1' + ), ( + Join-Path $moduleRoot 'M365.Toolkit.psm1' + ) + Get-ChildItem -Path ( + Join-Path $moduleRoot 'src\Private' + ), ( + Join-Path $moduleRoot 'src\Public' + ) -Recurse -File | + Where-Object Extension -In '.ps1', '.psm1', '.psd1', '.ps1xml', '.cdxml' + ) + + foreach ($file in $moduleFiles) { + Write-Host "[$(Get-Date -Format o)] Signing $($file.FullName)" + $signingParameters = @{ + FilePath = $file.FullName + Certificate = $certificate + HashAlgorithm = 'SHA256' + IncludeChain = 'All' + } + if (-not $isSelfSigned) { + $signingParameters.TimestampServer = $TimestampServer.AbsoluteUri + } + + $signature = Set-AuthenticodeSignature @signingParameters + Write-Host "[$(Get-Date -Format o)] Signature status: $($signature.Status)" + + $acceptableStatus = if ($isSelfSigned) { + $signature.Status -in @( + [System.Management.Automation.SignatureStatus]::Valid, + [System.Management.Automation.SignatureStatus]::UnknownError + ) + } + else { + $signature.Status -eq [System.Management.Automation.SignatureStatus]::Valid + } + + if ( + -not $acceptableStatus -or + $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -ne $certificate.Thumbprint + ) { + throw "Failed to sign '$($file.FullName)': $($signature.StatusMessage)" + } + } + + $invalidSignatures = @( + foreach ($file in $moduleFiles) { + $signature = Get-AuthenticodeSignature -FilePath $file.FullName + $acceptableStatus = if ($isSelfSigned) { + $signature.Status -in @( + [System.Management.Automation.SignatureStatus]::Valid, + [System.Management.Automation.SignatureStatus]::UnknownError + ) + } + else { + $signature.Status -eq [System.Management.Automation.SignatureStatus]::Valid + } + + if ( + -not $acceptableStatus -or + $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -ne $certificate.Thumbprint + ) { + $signature + } + } + ) + if ($invalidSignatures.Count -gt 0) { + throw "Signature validation failed for: $($invalidSignatures.Path -join ', ')" + } + + Write-Host "Signed and validated $($moduleFiles.Count) module files." +} +finally { + foreach ($pfxCertificate in $pfxCertificates) { + $pfxCertificate.Dispose() + } + if ($null -ne $pfxBytes) { + [Array]::Clear($pfxBytes, 0, $pfxBytes.Length) + } +} diff --git a/build/Update-ModuleVersion.ps1 b/build/Update-ModuleVersion.ps1 index e825cc9..a3da8d3 100644 --- a/build/Update-ModuleVersion.ps1 +++ b/build/Update-ModuleVersion.ps1 @@ -10,6 +10,11 @@ Version component to increment: Major, Minor, Patch, or Build. .PARAMETER Version Explicit version to assign instead of incrementing the current version. +.PARAMETER Prerelease + Prerelease label to apply. PowerShell Gallery requires a three-part module + version when this value is used. +.PARAMETER ClearPrerelease + Removes an existing prerelease label from the manifest. .EXAMPLE .\Update-ModuleVersion.ps1 -ManifestPath .\M365.Toolkit.psd1 -Bump Patch .EXAMPLE @@ -29,7 +34,14 @@ param( $parsedVersion = $null [version]::TryParse($_, [ref]$parsedVersion) })] - [string]$Version + [string]$Version, + + [Parameter()] + [ValidatePattern('^[0-9A-Za-z-]+$')] + [string]$Prerelease, + + [Parameter()] + [switch]$ClearPrerelease ) if (-not (Test-Path -Path $ManifestPath)) { @@ -50,11 +62,35 @@ else { 'Major' { [version]::new($currentVersion.Major + 1, 0, 0, 0) } 'Minor' { [version]::new($currentVersion.Major, $currentVersion.Minor + 1, 0, 0) } 'Patch' { [version]::new($currentVersion.Major, $currentVersion.Minor, $build + 1, 0) } - 'Build' { [version]::new($currentVersion.Major, $currentVersion.Minor, $build, $revision + 1) } + 'Build' { + if ($Prerelease) { + [version]::new($currentVersion.Major, $currentVersion.Minor, $build + 1) + } + else { + [version]::new($currentVersion.Major, $currentVersion.Minor, $build, $revision + 1) + } + } + } +} + +$updateParameters = @{ + Path = $ManifestPath + ModuleVersion = $newVersion.ToString() +} +if ($Prerelease) { + if ($newVersion.Revision -ge 0) { + throw 'A prerelease module version must contain exactly three numeric parts.' } + $updateParameters.Prerelease = $Prerelease } -Update-ModuleManifest -Path $ManifestPath -ModuleVersion $newVersion.ToString() +Update-ModuleManifest @updateParameters + +if ($ClearPrerelease) { + $manifestContent = Get-Content -Path $ManifestPath -Raw + $manifestContent = $manifestContent -replace "(?m)^(\s*)Prerelease\s*=\s*'[^']*'\s*$", '$1# Prerelease = ''''' + Set-Content -Path $ManifestPath -Value $manifestContent -Encoding utf8NoBOM -NoNewline +} Write-Host "Bumped module version: $currentVersion -> $newVersion" diff --git a/testResults.xml b/testResults.xml new file mode 100644 index 0000000..29553b1 --- /dev/null +++ b/testResults.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 22fee87e7df739b885781676b48dd3f00af12c6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:36:31 +0000 Subject: [PATCH 2/9] chore: bump preview version to 1.0.1-preview [skip ci] --- M365.Toolkit.psd1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/M365.Toolkit.psd1 b/M365.Toolkit.psd1 index d672604..ca69fe3 100644 --- a/M365.Toolkit.psd1 +++ b/M365.Toolkit.psd1 @@ -12,7 +12,7 @@ RootModule = 'M365.Toolkit.psm1' # Version number of this module. -ModuleVersion = '1.0.0.7' +ModuleVersion = '1.0.1' # Supported PSEditions CompatiblePSEditions = 'Desktop', 'Core' @@ -111,7 +111,7 @@ PrivateData = @{ # ReleaseNotes = '' # Prerelease string of this module - # Prerelease = '' + Prerelease = 'preview' # Flag to indicate whether the module requires explicit user acceptance for install/update/save # RequireLicenseAcceptance = $false From 466fca85543c535334a4c4993435c43329c11566 Mon Sep 17 00:00:00 2001 From: Shawn Fagan Date: Thu, 16 Jul 2026 21:47:35 -0400 Subject: [PATCH 3/9] chore: remove obsolete test results file --- testResults.xml | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 testResults.xml diff --git a/testResults.xml b/testResults.xml deleted file mode 100644 index 29553b1..0000000 --- a/testResults.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From b35f4d12e9202054bf63432822ffd50b334c0f17 Mon Sep 17 00:00:00 2001 From: Shawn Fagan Date: Thu, 16 Jul 2026 21:48:00 -0400 Subject: [PATCH 4/9] chore: add testResults.xml to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e098148 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +testResults.xml From 795d965405742b83ec231a4188bafd7004c47dea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:49:06 +0000 Subject: [PATCH 5/9] chore: bump preview version to 1.0.2-preview [skip ci] --- M365.Toolkit.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/M365.Toolkit.psd1 b/M365.Toolkit.psd1 index ca69fe3..34b3399 100644 --- a/M365.Toolkit.psd1 +++ b/M365.Toolkit.psd1 @@ -12,7 +12,7 @@ RootModule = 'M365.Toolkit.psm1' # Version number of this module. -ModuleVersion = '1.0.1' +ModuleVersion = '1.0.2' # Supported PSEditions CompatiblePSEditions = 'Desktop', 'Core' From 6bd1e1b86bde188c08faf5b8476b71a5ccc010ba Mon Sep 17 00:00:00 2001 From: Shawn Fagan Date: Tue, 28 Jul 2026 09:18:28 -0400 Subject: [PATCH 6/9] feat: add step to strip authenticode signatures before publishing to PowerShell Gallery --- .github/workflows/build-Production.yml | 19 +++++++++++++++++++ .github/workflows/develop-prerelease.yml | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/.github/workflows/build-Production.yml b/.github/workflows/build-Production.yml index 880edf6..2b89496 100644 --- a/.github/workflows/build-Production.yml +++ b/.github/workflows/build-Production.yml @@ -94,6 +94,25 @@ jobs: prerelease: false token: ${{ secrets.GITHUB_TOKEN }} + - name: Strip authenticode signatures before publish + if: github.event_name != 'pull_request' + shell: pwsh + run: | + # PSGallery does not require signed files. Publishing files signed with a + # self-signed certificate causes Install-Module to fail on end-user machines + # because the certificate is not in their trusted roots. Strip signature + # blocks here so the gallery copy is unsigned. + $extensions = '*.ps1', '*.psm1', '*.psd1', '*.ps1xml', '*.cdxml' + $files = Get-ChildItem -Path . -Recurse -Include $extensions + foreach ($file in $files) { + $content = Get-Content -Path $file.FullName -Raw + if ($content -match '# SIG # Begin signature block') { + $stripped = $content -replace '(?s)\r?\n# SIG # Begin signature block.+', '' + Set-Content -Path $file.FullName -Value $stripped -NoNewline -Encoding UTF8 + Write-Host "Stripped signature from $($file.FullName)" + } + } + - name: Publish module to PowerShell Gallery if: github.event_name != 'pull_request' shell: pwsh diff --git a/.github/workflows/develop-prerelease.yml b/.github/workflows/develop-prerelease.yml index 21067bd..bed5f6f 100644 --- a/.github/workflows/develop-prerelease.yml +++ b/.github/workflows/develop-prerelease.yml @@ -82,6 +82,24 @@ jobs: -PfxBase64 $env:CODESIGNINGPFXBASE64 ` -PfxPassword $env:CODESIGNINGPFXPASSWORD + - name: Strip authenticode signatures before publish + shell: pwsh + run: | + # PSGallery does not require signed files. Publishing files signed with a + # self-signed certificate causes Install-Module to fail on end-user machines + # because the certificate is not in their trusted roots. Strip signature + # blocks here so the gallery copy is unsigned. + $extensions = '*.ps1', '*.psm1', '*.psd1', '*.ps1xml', '*.cdxml' + $files = Get-ChildItem -Path . -Recurse -Include $extensions + foreach ($file in $files) { + $content = Get-Content -Path $file.FullName -Raw + if ($content -match '# SIG # Begin signature block') { + $stripped = $content -replace '(?s)\r?\n# SIG # Begin signature block.+', '' + Set-Content -Path $file.FullName -Value $stripped -NoNewline -Encoding UTF8 + Write-Host "Stripped signature from $($file.FullName)" + } + } + - name: Publish preview to PowerShell Gallery shell: pwsh env: From d5b0da4dd22c73a5da71d0056190851ba96e701f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:19:33 +0000 Subject: [PATCH 7/9] chore: bump preview version to 1.0.3-preview [skip ci] --- M365.Toolkit.psd1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/M365.Toolkit.psd1 b/M365.Toolkit.psd1 index 34b3399..2ff64da 100644 --- a/M365.Toolkit.psd1 +++ b/M365.Toolkit.psd1 @@ -3,7 +3,7 @@ # # Generated by: Shawn Fagan # -# Generated on: 7/17/2026 +# Generated on: 7/28/2026 # @{ @@ -12,7 +12,7 @@ RootModule = 'M365.Toolkit.psm1' # Version number of this module. -ModuleVersion = '1.0.2' +ModuleVersion = '1.0.3' # Supported PSEditions CompatiblePSEditions = 'Desktop', 'Core' From b5987ced7dda1234ffa0d6fb5b8fc1a3e5b5ba76 Mon Sep 17 00:00:00 2001 From: Shawn Fagan Date: Tue, 28 Jul 2026 11:18:31 -0400 Subject: [PATCH 8/9] feat: enhance workflows for versioning and publishing, update README for clarity --- .github/workflows/build-Production.yml | 56 ++++++++++++++++++++++---- .github/workflows/prepare-main-pr.yml | 39 +++--------------- .github/workflows/version-bump.yml | 10 ++++- README.md | 13 +++--- build/Update-ModuleVersion.ps1 | 8 ++-- 5 files changed, 72 insertions(+), 54 deletions(-) diff --git a/.github/workflows/build-Production.yml b/.github/workflows/build-Production.yml index 2b89496..c1bab92 100644 --- a/.github/workflows/build-Production.yml +++ b/.github/workflows/build-Production.yml @@ -4,6 +4,8 @@ on: push: branches: - main + tags: + - 'v*' paths-ignore: - docs/** - .github/workflows/pages.yml @@ -25,24 +27,62 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Read module version from manifest id: version shell: pwsh run: | $manifest = Import-PowerShellDataFile -Path ./M365.Toolkit.psd1 - $moduleVersion = [string]$manifest.ModuleVersion - if ([string]::IsNullOrWhiteSpace($moduleVersion)) { + $moduleVersionText = [string]$manifest.ModuleVersion + if ([string]::IsNullOrWhiteSpace($moduleVersionText)) { throw 'ModuleVersion was not found in M365.Toolkit.psd1.' } - if (-not [string]::IsNullOrWhiteSpace([string]$manifest.PrivateData.PSData.Prerelease)) { + if ( + '${{ github.event_name }}' -ne 'pull_request' -and + -not [string]::IsNullOrWhiteSpace([string]$manifest.PrivateData.PSData.Prerelease) + ) { throw 'Production releases cannot contain a prerelease label.' } + $moduleVersion = [version]$moduleVersionText $releaseTag = "v$moduleVersion" + $publishRelease = '${{ github.event_name }}' -ne 'pull_request' + + if ('${{ github.ref_type }}' -eq 'tag') { + $tagName = '${{ github.ref_name }}' + if ($tagName -notmatch '^v\d+\.0\.0$') { + throw "Major release tags must use the format v.0.0. Received: $tagName" + } + if ($tagName -ne $releaseTag) { + throw "Tag $tagName does not match manifest version $moduleVersion." + } + } + elseif ('${{ github.event_name }}' -ne 'pull_request') { + $releasedMajors = @( + git tag --list | + ForEach-Object { + $tagVersion = $null + if ( + $_ -match '^v?(\d+\.\d+\.\d+(?:\.\d+)?)$' -and + [version]::TryParse($Matches[1], [ref]$tagVersion) + ) { + $tagVersion.Major + } + } | + Sort-Object -Unique + ) + + if ($releasedMajors.Count -gt 0 -and $moduleVersion.Major -notin $releasedMajors) { + Write-Host "Skipping publish. Major version $($moduleVersion.Major) requires tag v$($moduleVersion.Major).0.0." + $publishRelease = $false + } + } - "module_version=$moduleVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "module_version=$moduleVersionText" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "release_tag=$releaseTag" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "publish_release=$($publishRelease.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Install test dependencies shell: pwsh @@ -63,7 +103,7 @@ jobs: Invoke-Pester -Path ./src/tests/M365.Toolkit.Tests.ps1 -CI - name: Sign module scripts - if: github.event_name != 'pull_request' + if: steps.version.outputs.publish_release == 'true' shell: pwsh env: CODESIGNINGPFXBASE64: ${{ secrets.CODESIGNINGPFXBASE64 }} @@ -82,7 +122,7 @@ jobs: -PfxPassword $env:CODESIGNINGPFXPASSWORD - name: Create or update GitHub release - if: github.event_name != 'pull_request' + if: steps.version.outputs.publish_release == 'true' uses: ncipollo/release-action@v1 with: tag: ${{ steps.version.outputs.release_tag }} @@ -95,7 +135,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Strip authenticode signatures before publish - if: github.event_name != 'pull_request' + if: steps.version.outputs.publish_release == 'true' shell: pwsh run: | # PSGallery does not require signed files. Publishing files signed with a @@ -114,7 +154,7 @@ jobs: } - name: Publish module to PowerShell Gallery - if: github.event_name != 'pull_request' + if: steps.version.outputs.publish_release == 'true' shell: pwsh env: PSGALLERY_API_KEY: ${{ secrets.PSGALLERYAPIKEY }} diff --git a/.github/workflows/prepare-main-pr.yml b/.github/workflows/prepare-main-pr.yml index 3324e2d..f7292fa 100644 --- a/.github/workflows/prepare-main-pr.yml +++ b/.github/workflows/prepare-main-pr.yml @@ -6,7 +6,7 @@ on: - main types: - opened - - labeled + - reopened permissions: contents: write @@ -33,40 +33,11 @@ jobs: - name: Set stable pull request version id: version shell: pwsh - env: - EVENT_ACTION: ${{ github.event.action }} - EVENT_LABEL: ${{ github.event.label.name }} - PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} run: | - $versionPattern = '^v?\d+\.\d+\.\d+(?:\.\d+)?$' - $versionLabels = @( - $env:PR_LABELS_JSON | - ConvertFrom-Json | - Where-Object { $_ -match $versionPattern } - ) - - if ($versionLabels.Count -gt 1) { - throw "The pull request has multiple version labels: $($versionLabels -join ', ')" - } - - if ($env:EVENT_ACTION -eq 'labeled' -and $env:EVENT_LABEL -notmatch $versionPattern) { - 'changed=false' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - exit 0 - } - - if ($versionLabels.Count -eq 1) { - $explicitVersion = $versionLabels[0] -replace '^v', '' - $newVersion = ./build/Update-ModuleVersion.ps1 ` - -ManifestPath ./M365.Toolkit.psd1 ` - -Version $explicitVersion ` - -ClearPrerelease - } - else { - $newVersion = ./build/Update-ModuleVersion.ps1 ` - -ManifestPath ./M365.Toolkit.psd1 ` - -Bump Patch ` - -ClearPrerelease - } + $newVersion = ./build/Update-ModuleVersion.ps1 ` + -ManifestPath ./M365.Toolkit.psd1 ` + -Bump Minor ` + -ClearPrerelease 'changed=true' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "new_version=$newVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 7e995b8..25f74de 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -12,9 +12,8 @@ on: - build - patch - minor - - major explicit_version: - description: "Optional explicit version (example: 1.0.1.0)" + description: "Optional explicit version within the current major (example: 1.2.0)" required: false type: string @@ -46,6 +45,13 @@ jobs: $newVersion = ./build/Update-ModuleVersion.ps1 -ManifestPath ./M365.Toolkit.psd1 -Bump '${{ inputs.bump_type }}' } else { + $manifest = Import-PowerShellDataFile -Path ./M365.Toolkit.psd1 + $currentVersion = [version]$manifest.ModuleVersion + $requestedVersion = [version]$explicitVersion + if ($requestedVersion.Major -ne $currentVersion.Major) { + throw 'Major versions can only be introduced by a matching v.0.0 git tag.' + } + $newVersion = ./build/Update-ModuleVersion.ps1 -ManifestPath ./M365.Toolkit.psd1 -Version $explicitVersion } diff --git a/README.md b/README.md index e35d639..2a90873 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ PowerShell Module for Microsoft 365 Toolkit ## Versioning Process For Publishing -Use the GitHub workflow `.github/workflows/version-bump.yml` to update the module version in `M365.Toolkit.psd1`. +M365.Toolkit follows the PnP.PowerShell numeric version pattern: -1. Open Actions and run **Bump Module Version**. -2. Select `bump_type` (`build`, `patch`, `minor`, or `major`) or provide `explicit_version`. -3. The workflow updates `ModuleVersion`, commits the change, and pushes to the current branch. -4. After merge/push to the target branch, the publish workflow uses the new manifest version for release tagging and PowerShell Gallery publish. +- Develop prereleases increment the third component and use the `preview` label, for example `1.1.12-preview`. +- A pull request to `main` increments the minor component, resets the third component to zero, and removes the prerelease label, for example `1.1.12-preview` becomes `1.2.0`. +- A new major starts at `.0.0` and is released only by pushing a matching tag, for example `v2.0.0`. The tag must point to a commit whose manifest version is `2.0.0`. + +The manual **Bump Module Version** workflow can update build, patch, or minor versions within the current major. It rejects explicit versions that change the major component. ## Production Code Signing @@ -33,4 +34,4 @@ A self-signed certificate is signed without an external timestamp. The workflow Each non-bot push to `develop` increments the three-part module build version, applies the `preview` prerelease label, and publishes the signed package to PowerShell Gallery. The workflow commits the new version to `develop` without triggering another build. -When a pull request is opened against `main`, the workflow increments the patch version and removes the prerelease label. Add a label such as `v1.2.3.4` to set that exact version instead. Adding a non-version label does not change the manifest. +When a pull request is opened or reopened against `main`, the workflow increments the minor version, resets the third component to zero, and removes the prerelease label. Version labels do not override this behavior. diff --git a/build/Update-ModuleVersion.ps1 b/build/Update-ModuleVersion.ps1 index a3da8d3..8d849ce 100644 --- a/build/Update-ModuleVersion.ps1 +++ b/build/Update-ModuleVersion.ps1 @@ -18,7 +18,7 @@ .EXAMPLE .\Update-ModuleVersion.ps1 -ManifestPath .\M365.Toolkit.psd1 -Bump Patch .EXAMPLE - .\Update-ModuleVersion.ps1 -ManifestPath .\M365.Toolkit.psd1 -Version 1.2.0.0 + .\Update-ModuleVersion.ps1 -ManifestPath .\M365.Toolkit.psd1 -Version 1.2.0 #> [CmdletBinding()] param( @@ -59,9 +59,9 @@ else { $revision = [Math]::Max(0, $currentVersion.Revision) switch ($Bump) { - 'Major' { [version]::new($currentVersion.Major + 1, 0, 0, 0) } - 'Minor' { [version]::new($currentVersion.Major, $currentVersion.Minor + 1, 0, 0) } - 'Patch' { [version]::new($currentVersion.Major, $currentVersion.Minor, $build + 1, 0) } + 'Major' { [version]::new($currentVersion.Major + 1, 0, 0) } + 'Minor' { [version]::new($currentVersion.Major, $currentVersion.Minor + 1, 0) } + 'Patch' { [version]::new($currentVersion.Major, $currentVersion.Minor, $build + 1) } 'Build' { if ($Prerelease) { [version]::new($currentVersion.Major, $currentVersion.Minor, $build + 1) From 69efcec1b5b48bef4c1b0e881c8461ca6caed237 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:19:35 +0000 Subject: [PATCH 9/9] chore: bump preview version to 1.0.4-preview [skip ci] --- M365.Toolkit.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/M365.Toolkit.psd1 b/M365.Toolkit.psd1 index 2ff64da..035a7ad 100644 --- a/M365.Toolkit.psd1 +++ b/M365.Toolkit.psd1 @@ -12,7 +12,7 @@ RootModule = 'M365.Toolkit.psm1' # Version number of this module. -ModuleVersion = '1.0.3' +ModuleVersion = '1.0.4' # Supported PSEditions CompatiblePSEditions = 'Desktop', 'Core'