diff --git a/.github/workflows/powershell.yml b/.github/workflows/powershell.yml index 1b9d0ab..1e18e3c 100644 --- a/.github/workflows/powershell.yml +++ b/.github/workflows/powershell.yml @@ -7,6 +7,11 @@ on: - 'Public/**' - 'Private/**' - 'Tests/**' + - 'scripts/**' + - 'docs/**' + - 'README.md' + - 'CHANGELOG.md' + - 'LICENSE' - 'IT-ToolBox.psd1' - 'IT-ToolBox.psm1' - '.github/workflows/powershell.yml' @@ -15,6 +20,11 @@ on: - 'Public/**' - 'Private/**' - 'Tests/**' + - 'scripts/**' + - 'docs/**' + - 'README.md' + - 'CHANGELOG.md' + - 'LICENSE' - 'IT-ToolBox.psd1' - 'IT-ToolBox.psm1' - '.github/workflows/powershell.yml' @@ -47,7 +57,7 @@ jobs: if ($PSVersionTable.PSVersion -lt [version]'7.4') { throw 'PowerShell 7.4 or later is required.' } - foreach ($file in Get-ChildItem ./Public, ./Private, ./Tests -Filter *.ps1 -Recurse) { + foreach ($file in Get-ChildItem ./Public, ./Private, ./Tests, ./scripts -Filter *.ps1 -Recurse) { $tokens = $null $parseErrors = $null $null = [System.Management.Automation.Language.Parser]::ParseFile( @@ -84,3 +94,6 @@ jobs: $configuration.Run.Exit = $true $configuration.Output.Verbosity = 'Detailed' Invoke-Pester -Configuration $configuration + + - name: Build supported module archive + run: ./scripts/Build-Module.ps1 diff --git a/.gitignore b/.gitignore index ec493bc..0ed9070 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ *.Package.ps1 IT-ToolBox.psproj CustomMenu.inf*tempPoint* + +# Local build output and macOS metadata +/artifacts/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index e86775b..53b146e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## Supported module packaging (unreleased) + +- Add a local ZIP builder with conventional IT-ToolBox/version module layout. +- Package supported code and documentation only; exclude legacy and development files. +- Validate staged syntax and manifest; refuse to overwrite existing archives. +- Add real archive-content and isolated import/logger tests to the three-platform CI. +- Document preview installation; retain alpha1 metadata without publishing or signing. + ## Integration boundary review (unreleased) - Remove the obsolete Close-AzureSession helper and document service-specific authentication ownership. diff --git a/README.md b/README.md index dbd8aa5..cecb520 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Redaction is opt-in and does not guarantee detection of every secret. - SCP and GnuPG wrappers and bundled WinSCP binaries are removed. Separate modules will own file transfer and OpenPGP; no replacement is bundled here. -- `Legacy/` retains historical string encryption and AzureAD helpers for reference. +- `Legacy/` retains historical string encryption helpers for migration reference. - All commands formerly retained in `Staging/v3/` now have supported implementations. Its README records the migration; historical service integrations remain excluded. - Only the twenty-nine listed commands are exported. Private helpers, variables and aliases @@ -78,6 +78,12 @@ Redaction is opt-in and does not guarantee detection of every secret. ## Tests and CI +For a clean installable preview archive, run `./scripts/Build-Module.ps1` in +PowerShell after running the tests. The archive includes supported module code +and documentation, excluding Legacy, Staging and development files. See +[build and installation instructions](./docs/Packaging.md). Nothing is published +or installed automatically. + ```powershell Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser Invoke-Pester ./Tests diff --git a/Staging/README.md b/Staging/README.md index f19b417..25a6854 100644 --- a/Staging/README.md +++ b/Staging/README.md @@ -3,9 +3,8 @@ There are no executable candidates in this directory. All former v3 utility candidates have supported implementations in Public/. -Close-AzureSession is archived in Legacy/ because it uses AzureAD and calls the -absent Test-AzureSession helper. The global certificate-validation bypass snippet -has been removed; its historical source remains available in Git history. +Close-AzureSession and the global certificate-validation bypass snippet have been +removed. Their historical source remains available in Git history. Future candidates belong on a feature branch with tests before entering Public/. Staging and Legacy are never loaded by the module. See diff --git a/Tests/Packaging.Tests.ps1 b/Tests/Packaging.Tests.ps1 new file mode 100644 index 0000000..3b15afa --- /dev/null +++ b/Tests/Packaging.Tests.ps1 @@ -0,0 +1,65 @@ +BeforeAll { + $sourceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + $buildScript = Join-Path $sourceRoot 'scripts/Build-Module.ps1' + $package = & $buildScript -OutputDirectory (Join-Path $TestDrive 'output [literal]') + $extractRoot = Join-Path $TestDrive 'extracted' + [IO.Compression.ZipFile]::ExtractToDirectory($package.ArchivePath, $extractRoot) + $moduleRoot = Join-Path $extractRoot 'IT-ToolBox/3.0.0' +} + +Describe 'Installable module packaging' { + It 'uses the manifest preview version and conventional module layout' { + $package.Version | Should -Be '3.0.0-alpha1' + [IO.Path]::GetFileName($package.ArchivePath) | Should -Be 'IT-ToolBox-3.0.0-alpha1.zip' + Test-ModuleManifest (Join-Path $moduleRoot 'IT-ToolBox.psd1') -ErrorAction Stop | Should -Not -BeNullOrEmpty + Test-Path -LiteralPath (Join-Path $moduleRoot 'LICENSE') | Should -BeTrue + } + + It 'ships only supported code and documentation, excluding development and legacy files' { + foreach ($directory in @('Legacy', 'Staging', 'Tests', 'scripts', '.git', '.github', 'artifacts')) { + Test-Path -LiteralPath (Join-Path $moduleRoot $directory) | Should -BeFalse + } + $files = @(Get-ChildItem -LiteralPath $moduleRoot -File -Recurse -Force) + $files.Count | Should -Be $package.FileCount + foreach ($file in $files) { + $relative = [IO.Path]::GetRelativePath($moduleRoot, $file.FullName).Replace('\', '/') + $relative | Should -Match '^(IT-ToolBox\.psd1|IT-ToolBox\.psm1|LICENSE|README\.md|CHANGELOG\.md|(Public|Private)/[^/]+\.ps1|docs/[^/]+\.md)$' + $original = Join-Path $sourceRoot $relative + (Get-FileHash -LiteralPath $file.FullName).Hash | Should -Be (Get-FileHash -LiteralPath $original).Hash + } + } + + It 'imports the extracted module by name and exercises logging in a fresh process' { + $escapedRoot = $extractRoot.Replace("'", "''") + $code = @" +`$ErrorActionPreference = 'Stop' +`$env:PSModulePath = '$escapedRoot' + [IO.Path]::PathSeparator + `$env:PSModulePath +`$module = Import-Module IT-ToolBox -RequiredVersion 3.0.0 -Force -PassThru +New-LogEntry -LogMessage 'Packaged logger' -BufferOnly +[pscustomobject]@{ + ExportCount = `$module.ExportedFunctions.Count + ModuleBase = `$module.ModuleBase + Buffer = @(New-LogEntry -GetBuffer) +} | ConvertTo-Json -Compress +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code)) + $executable = Join-Path $PSHOME $(if ($IsWindows) { 'pwsh.exe' } else { 'pwsh' }) + $output = & $executable -NoProfile -NonInteractive -EncodedCommand $encoded + $LASTEXITCODE | Should -Be 0 + $result = $output | ConvertFrom-Json + $result.ModuleBase | Should -Be $moduleRoot + $result.ExportCount | Should -Be 29 + $result.Buffer.Count | Should -Be 1 + $result.Buffer[0] | Should -Match '\[INFO\]: Packaged logger$' + } + + It 'refuses to overwrite an existing archive without changing its contents' { + $hash = (Get-FileHash -LiteralPath $package.ArchivePath).Hash + { & $buildScript -OutputDirectory ([IO.Path]::GetDirectoryName($package.ArchivePath)) } | Should -Throw '*already exists*' + (Get-FileHash -LiteralPath $package.ArchivePath).Hash | Should -Be $hash + } + + It 'rejects non-filesystem output providers' { + { & $buildScript -OutputDirectory 'Env:ITToolBoxPackage' } | Should -Throw '*filesystem*' + } +} diff --git a/docs/Packaging.md b/docs/Packaging.md new file mode 100644 index 0000000..b8eeb82 --- /dev/null +++ b/docs/Packaging.md @@ -0,0 +1,37 @@ +# Build and install the modernization preview + +Run from the repository root using PowerShell 7.4 or later: + +```powershell +Invoke-Pester ./Tests +./scripts/Build-Module.ps1 +``` + +The script creates artifacts/IT-ToolBox-3.0.0-alpha1.zip and returns its path, +version and file count. It takes the version from the manifest without modifying +it. Use -OutputDirectory to choose another local destination. An existing archive +causes a terminating error; use a different destination for another build. + +The ZIP contains IT-ToolBox/3.0.0/ with the manifest, loader, Public and Private +implementation files, license, README, changelog and Markdown documents in docs/. +Tests, build scripts, Legacy, Staging, Git metadata and external binaries are +excluded. Historical encryption migration requires the original repository source +in a separate session; the old decoder is not distributed in this archive. + +Extract into a new directory, then verify or import the extracted manifest: + +```powershell +Expand-Archive -LiteralPath './artifacts/IT-ToolBox-3.0.0-alpha1.zip' -DestinationPath './preview-install' +Import-Module './preview-install/IT-ToolBox/3.0.0/IT-ToolBox.psd1' -Force +Get-Command -Module IT-ToolBox +``` + +For discovery by name, put the extracted IT-ToolBox directory under a directory +listed in $env:PSModulePath. Review existing installations before copying there; +this script does not install or replace a module for you. + +CI runs archive-content and fresh-process import tests on Windows, Linux and macOS, +then builds the archive locally. No publishing, release creation or signing is +performed. ZIP timestamps are not normalized, so identical source builds need not +have identical archive hashes. This remains an alpha preview; the package process +does not establish live AD, remote CIM or service authentication compatibility. diff --git a/scripts/Build-Module.ps1 b/scripts/Build-Module.ps1 new file mode 100644 index 0000000..69869a8 --- /dev/null +++ b/scripts/Build-Module.ps1 @@ -0,0 +1,76 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS + Build a local installable ZIP from supported module files only. +.DESCRIPTION + Does not publish, install, sign or change the module version. Existing archives + are never overwritten. Build staging is removed even when packaging fails. +#> +[CmdletBinding()] +param ( + [ValidateNotNullOrEmpty()] + [string]$OutputDirectory = (Join-Path $PSScriptRoot '../artifacts') +) + +$ErrorActionPreference = 'Stop' +$sourceRoot = Split-Path $PSScriptRoot -Parent +$manifest = Import-PowerShellDataFile (Join-Path $sourceRoot 'IT-ToolBox.psd1') +$version = [string]$manifest.ModuleVersion +$prerelease = [string]$manifest.PrivateData.PSData.Prerelease +if ($prerelease) { $version += "-$prerelease" } +if ($version -notmatch '^\d+\.\d+\.\d+(\.\d+)?(-[A-Za-z0-9]+)?$') { + throw 'Manifest version cannot be used as an archive filename.' +} +$provider = $null +$drive = $null +$outputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + $OutputDirectory, [ref]$provider, [ref]$drive +) +if ($provider.Name -ne 'FileSystem') { throw 'OutputDirectory must be a filesystem path.' } +$archivePath = Join-Path $outputPath "IT-ToolBox-$version.zip" +if (Test-Path -LiteralPath $archivePath) { throw "Archive already exists: $archivePath" } + +$stageRoot = Join-Path ([IO.Path]::GetTempPath()) ('ITToolBox-build-' + [guid]::NewGuid()) +$packageRoot = Join-Path $stageRoot 'package' +$moduleRoot = Join-Path $packageRoot (Join-Path 'IT-ToolBox' ([string]$manifest.ModuleVersion)) +try { + $null = [IO.Directory]::CreateDirectory($moduleRoot) + $files = @(foreach ($name in @('IT-ToolBox.psd1', 'IT-ToolBox.psm1', 'LICENSE', 'README.md', 'CHANGELOG.md')) { + Get-Item -LiteralPath (Join-Path $sourceRoot $name) + }) + foreach ($directory in @('Public', 'Private', 'docs')) { + $filter = if ($directory -eq 'docs') { '*.md' } else { '*.ps1' } + $files += @(Get-ChildItem -LiteralPath (Join-Path $sourceRoot $directory) -Filter $filter -File | Sort-Object Name) + } + foreach ($file in $files) { + if ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Packaging symbolic links is not supported: $($file.FullName)" + } + $relative = [IO.Path]::GetRelativePath($sourceRoot, $file.FullName) + $destination = Join-Path $moduleRoot $relative + $null = [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) + [IO.File]::Copy($file.FullName, $destination, $false) + if ($file.Extension -in @('.ps1', '.psm1', '.psd1')) { + $tokens = $null + $parseErrors = $null + $null = [Management.Automation.Language.Parser]::ParseFile($destination, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count) { throw ($parseErrors | Out-String) } + } + } + $null = Test-ModuleManifest -Path (Join-Path $moduleRoot 'IT-ToolBox.psd1') -ErrorAction Stop + $temporaryArchive = Join-Path $stageRoot 'module.zip' + [IO.Compression.ZipFile]::CreateFromDirectory($packageRoot, $temporaryArchive) + $null = [IO.Directory]::CreateDirectory($outputPath) + [IO.File]::Move($temporaryArchive, $archivePath, $false) + [pscustomobject]@{ + ArchivePath = $archivePath + Version = $version + ModuleVersion = [string]$manifest.ModuleVersion + FileCount = $files.Count + } +} +finally { + if ([IO.Directory]::Exists($stageRoot)) { + [IO.Directory]::Delete($stageRoot, $true) + } +}