Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 72 additions & 141 deletions .github/workflows/build-Production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
push:
branches:
- main
tags:
- 'v*'
paths-ignore:
- docs/**
- .github/workflows/pages.yml
Expand All @@ -25,21 +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 (
'${{ 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<major>.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
}
Comment on lines +77 to +80
}

"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
Expand All @@ -60,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 }}
Expand All @@ -72,145 +115,14 @@ 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'
if: steps.version.outputs.publish_release == 'true'
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.version.outputs.release_tag }}
Expand All @@ -222,8 +134,27 @@ jobs:
prerelease: false
token: ${{ secrets.GITHUB_TOKEN }}

- name: Strip authenticode signatures before publish
if: steps.version.outputs.publish_release == 'true'
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'
if: steps.version.outputs.publish_release == 'true'
shell: pwsh
env:
PSGALLERY_API_KEY: ${{ secrets.PSGALLERYAPIKEY }}
Expand Down
130 changes: 130 additions & 0 deletions .github/workflows/develop-prerelease.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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
Comment on lines +56 to +65

- 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: 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:
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
Loading