From 80c0b246516e82c5fbbcc350986518be3203e9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 16:21:02 +0200 Subject: [PATCH 01/16] Add reusable dotnet publish bundle hooks --- ...SPublishModule.DotNetPublish.Quickstart.md | 22 ++ ...owerForge.BuildReuse.TestimoXTierBridge.md | 239 +++++++++++ .../Example.PackageBundleMsi.json | 111 ++++++ Module/Examples/DotNetPublish/README.md | 3 + Module/Examples/PluginCatalog/README.md | 28 ++ .../PluginCatalog/powerforge.plugins.json | 42 ++ Module/PSPublishModule.psd1 | 4 +- Module/PSPublishModule.psm1 | 4 +- PowerForge.Cli/Program.Command.DotNet.cs | 2 +- PowerForge.Cli/Program.Helpers.RunAndParse.cs | 110 ++++++ .../DotNetPublishPipelineRunnerBundleTests.cs | 283 +++++++++++++ .../DotNetPublishPipelineRunnerHookTests.cs | 173 ++++++++ PowerForge.Tests/ExportDetectorTests.cs | 44 +++ .../PowerForgePluginCatalogServiceTests.cs | 44 +++ .../DotNetPublish/DotNetPublishEnums.cs | 21 + .../Models/DotNetPublish/DotNetPublishPlan.cs | 99 +++++ .../Models/DotNetPublish/DotNetPublishSpec.cs | 137 +++++++ .../DotNetPublishPipelineRunner.Bundle.cs | 138 ++++++- .../DotNetPublishPipelineRunner.Hooks.cs | 143 +++++++ .../DotNetPublishPipelineRunner.Plan.cs | 373 +++++++++++++++++- .../DotNetPublishPipelineRunner.Run.cs | 7 +- Schemas/powerforge.dotnetpublish.schema.json | 103 +++++ Schemas/powerforge.plugins.schema.json | 61 +++ 23 files changed, 2170 insertions(+), 21 deletions(-) create mode 100644 Docs/PowerForge.BuildReuse.TestimoXTierBridge.md create mode 100644 Module/Examples/DotNetPublish/Example.PackageBundleMsi.json create mode 100644 Module/Examples/PluginCatalog/README.md create mode 100644 Module/Examples/PluginCatalog/powerforge.plugins.json create mode 100644 PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs create mode 100644 PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs create mode 100644 Schemas/powerforge.plugins.schema.json diff --git a/Docs/PSPublishModule.DotNetPublish.Quickstart.md b/Docs/PSPublishModule.DotNetPublish.Quickstart.md index e64b97df..b41ccc96 100644 --- a/Docs/PSPublishModule.DotNetPublish.Quickstart.md +++ b/Docs/PSPublishModule.DotNetPublish.Quickstart.md @@ -121,7 +121,11 @@ Depending on config, the run can emit: - Use `Bundles[]` when the shipped artifact is more than one publish output folder. - `PrepareFromTarget` selects the primary published target for the bundle. +- `PrimarySubdirectory` optionally places that primary publish output under a folder such as `Service` instead of copying it into the bundle root. - `Includes[]` let you copy sidecar targets such as services, workers, helper CLIs, or plugin payloads into subdirectories inside the bundle. +- `CopyItems[]` copies non-publish files or directories such as README files, static scripts, licenses, or product data into the bundle. +- `ModuleIncludes[]` copies built PowerShell module artefacts into the bundle, defaulting to `Modules/{moduleName}` so apps can ship their companion module without scraping user-profile installs. +- `GeneratedScripts[]` renders inline or file-based script templates into the bundle. Template values use `{{TokenName}}`; token values can use bundle tokens such as `{output}`, `{rid}`, `{framework}`, and `{moduleName}` for module includes. - `Scripts[]` let you run repo-specific finishing steps after the copy phase, for example exporting plugins, writing launchers, or producing metadata files. - `PostProcess.ArchiveDirectories[]` can zip bundle subdirectories after composition, which is useful for plugin packs or other nested payloads that should ship as archives. - `PostProcess.DeletePatterns[]` removes files or folders from the composed bundle using wildcard patterns such as `**/*.pdb` or `**/createdump.exe`. @@ -130,6 +134,24 @@ Depending on config, the run can emit: - Standalone bundle post-process is useful when a repo keeps a thin PowerShell wrapper for product-specific launcher/readme generation but wants archive/delete/metadata mechanics to stay in PowerForge. - Set `Zip`, `ZipPath`, or `ZipNameTemplate` on the bundle when the composed folder should also become a release-ready archive. +## Plugin Catalogs + +- Use `powerforge.plugins.json` when several plugin projects need one reusable catalog for folder export and NuGet package builds. +- `powerforge plugin export` and `Invoke-PowerForgePluginExport` publish selected plugin groups into folder-style plugin payloads. +- `powerforge plugin pack` and `Invoke-PowerForgePluginPack` pack selected plugin groups as NuGet packages and can optionally push the packages produced by the current run. +- Plugin entries can define groups, project path, preferred framework, package/assembly names, MSBuild properties, and optional schema-neutral manifest output. +- Keep branded product policy in repo config. For example, use `Manifest.FileName` and `Manifest.Properties` for `ix-plugin.json` or similar product-specific manifest shapes instead of hardcoding those names into PowerForge. +- Example config and commands live in `Module/Examples/PluginCatalog`. +- For app bundles, prefer a bundle `Scripts[]` step that runs plugin export into a plugin subdirectory, then use `PostProcess.ArchiveDirectories[]` when those plugin folders should be zipped before the final bundle archive. + +## Command Hooks + +- Use `Hooks[]` for reusable command steps that must run at predictable publish phases rather than in bespoke wrapper scripts. +- Supported phases are `BeforeRestore`, `BeforeBuild`, `BeforeTargetPublish`, `AfterTargetPublish`, `BeforeBundle`, and `AfterBundle`. +- Hooks support target/runtime/framework/style filters, arguments, working directory, environment variables, timeout, and required/optional failure policy. +- Hook arguments and environment values support tokens such as `{projectRoot}`, `{configuration}`, `{target}`, `{rid}`, `{framework}`, `{style}`, `{bundle}`, `{phase}`, and `{hook}`. +- Use `BeforeBuild` or `BeforeTargetPublish` for generated-source/catalog refreshes, and `BeforeBundle` or `AfterBundle` for product-specific package finishing that should stay repo-owned. + ## MSI From Bundle - Use `PrepareFromBundleId` on an installer when WiX harvesting should run against the composed portable bundle instead of the raw `PrepareFromTarget` publish folder. diff --git a/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md b/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md new file mode 100644 index 00000000..cbeefcb7 --- /dev/null +++ b/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md @@ -0,0 +1,239 @@ +# PowerForge Reusable Build Coverage: TestimoX and TierBridge + +Date: 2026-04-28 +Branch/worktree: `codex/catalog-reuse` in `C:\Support\GitHub\PSPublishModule-codex-catalog-reuse` + +This note maps the PowerShell-heavy build/deploy/catalog logic in TestimoX and TierBridge to the reusable capabilities already present in PSPublishModule/PowerForge, then proposes the smallest engine changes needed to make future repo scripts thin wrappers. + +## Consumer Inventory + +### TestimoX + +Relevant scripts and patterns: + +- `Build/Sync-PowerShellCatalog.ps1` + - Runs `dotnet run --project TestimoX.CLI ... ps-catalog --base-dir ... --output-dir ...`. + - Generates compiled PowerShell rule catalog source under `TestimoX/RulesPowerShell/RulesGenerated`. +- `Build/Publish-AOT.ps1` + - Owns restore/build, target selection (`CLI`, `Audit`, `Tools`, `Service`, `Agent`, `Both`, `All`), runtime matrix, style selection (`Portable`, `PortableCompat`, `PortableSize`, `AotSpeed`, `AotSize`), slim cleanup, optional catalog refresh, service install/start, and summary output. +- `Build/Deploy-CLI.ps1`, `Build/Deploy-Service.ps1`, `Build/Deploy-TestimoX.Monitoring.ps1`, `Build/Deploy-TestimoX.Agent.ps1` + - Publish, copy to distributable folders, zip outputs, and generate simple service scripts. +- `Build/Deploy.ps1` + - Composes CLI/service/monitoring outputs into a release root and writes `manifest.json` plus `manifest.txt`. +- `Build/Prepare-TestimoX.*-MSI.ps1` and `Build/Build-TestimoX.*-MSI.ps1` + - Stage payloads, write MSI manifests, harvest/build installer projects. +- `Build/*DashboardBenchmark*.ps1` and `Build/Helpers/Test-Dashboard*BenchmarkGate.ps1` + - Generate scenario data and enforce benchmark gates. +- `Module-TestimoX/Build/Build-Module.ps1`, `Module-ADPlayground/Build/Build-Module.ps1`, `Module-ComputerX/Build/Build-Module.ps1` + - Already use `Build-Module` / `New-ConfigurationBuild` for binary module merge/sign/package flows. + +### TierBridge + +Relevant scripts and patterns: + +- `Build/Deploy.ps1` + - Restores/builds solution, optionally builds/copies the PowerShell module, publishes service and CLI, signs binaries/scripts, creates package layout, generates wrapper scripts from templates, creates data folders from `appsettings.json`, copies README template, and zips the deployment. +- `Build/Rebuild.ps1` + - Stops service, builds/publishes service, preserves or clears local state, patches config from machine environment variables, configures audit policy, reinstalls service if needed, and starts/verifies service. +- `Build/Templates/*.wrapper.ps1` + - Thin user-facing wrappers that import the shipped module and call repo-specific module functions. +- `Module/Build/Build-Module.ps1` + - Already uses `Build-Module` / `New-ConfigurationBuild` for the PowerTierBridge module. + +## Existing PowerForge Coverage + +PowerForge already covers a large part of both repos' custom PowerShell: + +- Module builds: + - `Invoke-ModuleBuild` / `Build-Module` + - binary module merge/copy/sign/install + - `New-ConfigurationManifest`, `New-ConfigurationBuild`, `New-ConfigurationArtefact`, validation, docs, formatting, module dependency handling +- DotNet publish: + - `Invoke-DotNetPublish`, `New-DotNetPublishConfig`, `New-ConfigurationDotNet*` + - project catalog (`Projects[]` + `ProjectId`) + - runtime/framework/style matrix + - restore/build/publish separation + - `Portable`, `PortableCompat`, `PortableSize`, `AotSpeed`, `AotSize`, `FrameworkDependent` + - target-level and style-level MSBuild properties + - output path templates, zip output, slim cleanup, symbol/docs/ref pruning + - signing profiles and per-target/installer signing + - service script/metadata generation (`Install-Service.ps1`, `Uninstall-Service.ps1`, `Run-Once.ps1`) + - service recovery and service lifecycle execution + - config bootstrap rules + - state preservation rules for rebuild scenarios + - bundles with includes, bundle scripts, metadata, delete patterns, nested archive rules, and zip output + - MSI prepare/build and installer-from-bundle flow + - benchmark gates + - output manifests/checksums/run reports +- Plugin/catalog lane: + - `powerforge plugin export` + - `powerforge plugin pack` + - `PowerForgePluginCatalogService` + - PowerShell cmdlet source exists for `Invoke-PowerForgePluginExport` and `Invoke-PowerForgePluginPack` +- Standalone bundle post-process: + - `powerforge dotnet bundle-postprocess` + - PowerShell cmdlet source exists for `Invoke-PowerForgeBundlePostProcess` + +## Immediate Gaps + +### 1. Plugin and bundle-postprocess cmdlets are not exported by the generated module + +The cmdlet source and generated docs exist: + +- `PSPublishModule/Cmdlets/InvokePowerForgePluginExportCommand.cs` +- `PSPublishModule/Cmdlets/InvokePowerForgePluginPackCommand.cs` +- `PSPublishModule/Cmdlets/InvokePowerForgeBundlePostProcessCommand.cs` +- `Module/Docs/Invoke-PowerForgePluginExport.md` +- `Module/Docs/Invoke-PowerForgePluginPack.md` +- `Module/Docs/Invoke-PowerForgeBundlePostProcess.md` + +But `Module/PSPublishModule.psd1` and `Module/PSPublishModule.psm1` do not include those cmdlets in `CmdletsToExport`. + +Next step: regenerate/fix the module build output so these cmdlets are exported, and add a focused test that binary-detected cmdlets and manifest exports agree. Do not hand-edit the generated bootstrapper as the primary fix. + +### 2. No first-class pre-publish generated-source/catalog step + +TestimoX needs `ps-catalog` before publish. Today this can stay as a thin script before `Invoke-DotNetPublish`, but PowerForge lacks a declarative "run this command before build/publish" contract inside `DotNetPublishSpec`. + +Recommended engine feature: + +- `Hooks` or `Commands` in DotNet publish specs with phases such as `BeforeRestore`, `BeforeBuild`, `BeforeTargetPublish`, `AfterTargetPublish`, `BeforeBundle`, `AfterBundle`. +- command path, args with tokens, working directory, timeout, required/fail policy, environment variables, and output capture. +- plan-mode visibility without execution. + +This would make TestimoX's `Sync-PowerShellCatalog.ps1` a one-line wrapper around config instead of a prerequisite hidden in bespoke publish scripts. + +### 3. Generic wrapper-script generation is narrower than TierBridge needs + +PowerForge can generate service install/uninstall/run-once scripts, and it has an internal `ScriptTemplateRenderer`. TierBridge needs package scripts that import the shipped module and call arbitrary module functions (`Install-TierBridgeService`, `Set-TierBridgeConfig`, `Get-TierBridgePermission`, etc.). + +Recommended engine feature: + +- Bundle/service `ScriptsToGenerate[]` or `Templates[]`. +- Inputs: template path or embedded template, output path, token map, overwrite policy, optional signing profile. +- Built-in tokens for package paths, module names, service names, runtime/framework/style, bundle output, and artifact paths. + +Keep function names and parameters repo-owned. Move only the template rendering/copy/sign mechanics to PowerForge. + +### 4. No declarative "include built module artefact in app bundle" + +TierBridge deploy detects the module name from `Build-Module.ps1`, optionally builds it, finds an installed module path, and copies it into `Modules/`. + +Recommended engine feature: + +- A module artefact include in DotNet bundles: + - `ModuleBuild` reference or command hook + - expected module name + - source path from known PowerForge module artefact output, not user profile install fallback + - bundle destination such as `Modules/{moduleName}` + +This would let app bundles depend on PowerForge module build outputs without scraping scripts or reading user profile module installs. + +### 5. Product policy should remain repo-local + +Do not move these into PowerForge as generic features yet: + +- TestimoX rule semantics and the `ps-catalog` CLI implementation. +- TierBridge config mutation from `VirusTotalApi` / `TierBridgeGraphSecret`. +- TierBridge audit policy bootstrap and folder ACL policy. +- Product README text, branding, default install paths, support instructions. +- Local sibling dependency policy such as `UseLocalFileInspectorX`, except as normal MSBuild properties in config. + +PowerForge should expose hooks and reusable mechanics; repos should keep product choices. + +## Proposed Migration Plan + +### Phase 0: Unblock existing reusable surfaces + +1. Regenerate/fix module exports for: + - `Invoke-PowerForgePluginExport` + - `Invoke-PowerForgePluginPack` + - `Invoke-PowerForgeBundlePostProcess` +2. Add `Schemas/powerforge.plugins.schema.json` and a small `Module/Examples/PluginCatalog` example. +3. Add tests for default plugin config discovery and manifest export parity. +4. Add a short section to `Docs/PSPublishModule.DotNetPublish.Quickstart.md` linking plugin catalogs and bundle post-process to app bundles. + +### Phase 1: Engine primitives before consumer rewrites + +Implement the reusable pieces before replacing repo scripts. Scope them to the patterns already proven by TierBridge and TestimoX, not a broad "everything build-related" abstraction. + +1. DotNet publish hooks/commands with plan-mode output. Implemented in this branch as `Hooks[]`: + - `BeforeRestore` + - `BeforeBuild` + - `BeforeTargetPublish` + - `AfterTargetPublish` + - `BeforeBundle` + - `AfterBundle` +2. Generic generated script/template outputs for bundle/package scripts. Implemented in this branch as `Bundles[].GeneratedScripts[]`: + - template path or embedded template + - output path + - token map + - overwrite policy + - optional signing profile +3. Module artefact bundle includes. Implemented in this branch as `Bundles[].ModuleIncludes[]`: + - reference a PowerForge module build output + - copy into `Modules/{moduleName}` or another configured destination + - avoid user-profile module install discovery as the primary source +4. MSI/package hardening. Implemented in this branch with `Bundles[].PrimarySubdirectory`, `Bundles[].CopyItems[]`, module includes, generated scripts, and the existing `PrepareFromBundleId` installer flow: + - ensure installer prepare/build can consume composed bundle outputs + - keep service package metadata, wrapper scripts, README, module payload, and MSI payload in one declarative release layout +5. Manifest/export parity tests for generated module files so new binary cmdlets do not exist in docs/source but disappear from the shipped module. + +### Phase 2: TierBridge package and MSI pilot + +1. Add `Build/powerforge.dotnetpublish.json` with targets for `TierBridge.Service` and `TierBridge.CLI`. +2. Move `UseLocalFileInspectorX` into `DotNet.MsBuildProperties` or target `MsBuildProperties`. +3. Use service package + lifecycle for install/reinstall/start/verify mechanics. +4. Use state preservation for data/config/log paths that must survive rebuilds. +5. Use bundles for `Service`, `CLI`, `Scripts`, `Modules`, `README.md`, and deployment zip. +6. Generate TierBridge wrapper scripts through the engine template output feature. +7. Include the built PowerTierBridge module through the module artefact bundle include. +8. Build the MSI from the composed bundle/package layout. +9. Keep config mutation/audit bootstrap repo-local behind explicit hooks. + +TierBridge is the better first consumer because it exercises the package, service, module-include, generated-wrapper, signing, zip, and MSI path without TestimoX's additional generated catalog and AOT matrix complexity. + +### Phase 3: TestimoX publish/catalog/MSI pilot + +1. Add `Build/powerforge.dotnetpublish.json` with project catalog entries for: + - `TestimoX.CLI` + - `TestimoX.Audit` + - `TestimoX.Service` + - `TestimoX.Agent` + - `TestimoX.Monitoring` +2. Model publish targets with style/rid/framework matrices instead of script loops. +3. Move `Build/Sync-PowerShellCatalog.ps1` into a declarative `BeforeBuild` or `BeforeTargetPublish` hook. +4. Replace `Deploy-CLI.ps1` and `Deploy-Service.ps1` mechanics with `Invoke-DotNetPublish -ConfigPath ... -Target ...`. +5. Use bundles for the unified `CLI/Service/Monitoring` release layout and `Outputs` for manifest/checksum/report generation. +6. Migrate MSI staging/build scripts to `Installers[]` after publish parity is verified. + +### Phase 4: Generalize only after both pilots + +After TierBridge and TestimoX both build through PowerForge, promote only duplicated repo patterns into engine contracts: + +1. Optional package layout manifest that can replace repo-specific `manifest.json` / `manifest.txt` builders. +2. Additional hook phases only if both repos need them. +3. Additional script-template tokens only when they replace duplicated wrapper logic. +4. Higher-level release profiles only if the existing target/bundle/installer model becomes repetitive. + +## Target End State + +Each repo should converge to: + +```powershell +param( + [ValidateSet('Release','Debug')] + [string] $Configuration = 'Release', + [string[]] $Runtime = @('win-x64'), + [switch] $Plan +) + +$config = Join-Path $PSScriptRoot 'powerforge.dotnetpublish.json' +Invoke-DotNetPublish -ConfigPath $config -Configuration $Configuration -Runtimes $Runtime -Plan:$Plan -ExitCode +``` + +Repo-specific scripts should only: + +- choose profile/target/runtime defaults +- pass secrets or product-specific paths +- run product-specific catalog/config/audit hooks until those are intentionally generalized diff --git a/Module/Examples/DotNetPublish/Example.PackageBundleMsi.json b/Module/Examples/DotNetPublish/Example.PackageBundleMsi.json new file mode 100644 index 00000000..b3db58cc --- /dev/null +++ b/Module/Examples/DotNetPublish/Example.PackageBundleMsi.json @@ -0,0 +1,111 @@ +{ + "$schema": "../../../Schemas/powerforge.dotnetpublish.schema.json", + "SchemaVersion": 1, + "DotNet": { + "ProjectRoot": ".", + "Configuration": "Release", + "Restore": true, + "Build": true, + "Runtimes": [ + "win-x64" + ] + }, + "Targets": [ + { + "Name": "Service", + "ProjectPath": "src/Product.Service/Product.Service.csproj", + "Publish": { + "Framework": "net10.0", + "Styles": [ + "PortableCompat" + ], + "Service": { + "ServiceName": "Product.Service", + "DisplayName": "Product Service" + } + } + }, + { + "Name": "Cli", + "ProjectPath": "src/Product.Cli/Product.Cli.csproj", + "Publish": { + "Framework": "net10.0", + "Styles": [ + "PortableCompat" + ] + } + } + ], + "Bundles": [ + { + "Id": "package", + "PrepareFromTarget": "Service", + "PrimarySubdirectory": "Service", + "Zip": true, + "Includes": [ + { + "Target": "Cli", + "Subdirectory": "CLI" + } + ], + "CopyItems": [ + { + "SourcePath": "Build/README.package.md", + "DestinationPath": "README.md", + "Required": false + } + ], + "ModuleIncludes": [ + { + "ModuleName": "PowerProduct", + "SourcePath": "Build/Artifacts/Modules/PowerProduct" + } + ], + "GeneratedScripts": [ + { + "Template": "Import-Module \"$PSScriptRoot\\Modules\\{{ModuleName}}\\{{ModuleName}}.psd1\" -Force\r\n{{InstallCommand}}\r\n", + "OutputPath": "Scripts/Install-Service.ps1", + "Tokens": { + "ModuleName": "PowerProduct", + "InstallCommand": "Install-ProductService" + } + } + ], + "PostProcess": { + "DeletePatterns": [ + "**/*.pdb", + "**/createdump.exe" + ], + "Metadata": { + "Path": "package.manifest.json", + "Properties": { + "moduleName": "PowerProduct", + "installScript": "Scripts/Install-Service.ps1" + } + } + } + } + ], + "Installers": [ + { + "Id": "Product.MSI", + "PrepareFromTarget": "Service", + "PrepareFromBundleId": "package", + "InstallerProjectPath": "Installer/Product.Installer.wixproj", + "Harvest": "Auto" + } + ], + "Hooks": [ + { + "Id": "sync-generated-sources", + "Phase": "BeforeBuild", + "Command": "pwsh", + "Arguments": [ + "-NoProfile", + "-File", + "Build/Sync-GeneratedSources.ps1" + ], + "Required": false + } + ] +} diff --git a/Module/Examples/DotNetPublish/README.md b/Module/Examples/DotNetPublish/README.md index db520acf..219df5f9 100644 --- a/Module/Examples/DotNetPublish/README.md +++ b/Module/Examples/DotNetPublish/README.md @@ -10,6 +10,8 @@ Files: - rebuild/state preservation pattern with service lifecycle. - `Example.PortableBundleMsi.json` - desktop app + service sidecar + portable bundle + MSI pattern. +- `Example.PackageBundleMsi.json` + - service/CLI package layout with README copy, module payload include, generated wrapper script, bundle post-process, and MSI from the composed bundle. - `Example.StorePackage.json` - desktop app + Store/MSIX packaging project pattern. - `Example.StoreSubmit.json` @@ -53,6 +55,7 @@ Invoke-DotNetPublish -ConfigPath '.\powerforge.dotnetpublish.json' -ExitCode - `Example.ServiceMsi.json` expects a WiX installer project (`*.wixproj`). - `Example.RebuildState.json` is aimed at preserve/restore deployments and service-aware rebuild flows. - `Example.PortableBundleMsi.json` shows how to use `Bundles`, include sidecar targets, and build an MSI from the composed bundle instead of the raw publish output. +- `Example.PackageBundleMsi.json` is the starter for TierBridge-style packages: composed service/CLI payload, shipped PowerShell module, generated scripts, ZIP, and MSI from the bundle. - `Example.StorePackage.json` shows how to keep Store/MSIX packaging in the same PowerForge publish matrix as the app target. - `Example.StoreSubmit.json` shows how to submit a packaged-app ZIP to Partner Center after `StorePackages[]` produces the `*.msixupload` or `*.appxupload` artifact. - `Example.StoreDesktopSubmit.json` shows how to submit MSI/EXE metadata through the newer desktop Store submission API. diff --git a/Module/Examples/PluginCatalog/README.md b/Module/Examples/PluginCatalog/README.md new file mode 100644 index 00000000..6a520605 --- /dev/null +++ b/Module/Examples/PluginCatalog/README.md @@ -0,0 +1,28 @@ +# PowerForge Plugin Catalog Example + +Use `powerforge.plugins.json` when a repo needs one catalog for folder-style plugin export and NuGet plugin package builds. + +Plan folder export: + +```powershell +Invoke-PowerForgePluginExport -ConfigPath .\powerforge.plugins.json -Group public -Plan +``` + +Export plugin folders: + +```powershell +Invoke-PowerForgePluginExport -ConfigPath .\powerforge.plugins.json -Group public -OutputRoot .\Artifacts\Plugins -ExitCode +``` + +Pack plugin NuGet packages: + +```powershell +Invoke-PowerForgePluginPack -ConfigPath .\powerforge.plugins.json -Group pack-public -OutputRoot .\Artifacts\NuGet -ExitCode +``` + +The CLI exposes the same engine: + +```powershell +powerforge plugin export --config .\powerforge.plugins.json --group public --plan +powerforge plugin pack --config .\powerforge.plugins.json --group pack-public --output-root .\Artifacts\NuGet +``` diff --git a/Module/Examples/PluginCatalog/powerforge.plugins.json b/Module/Examples/PluginCatalog/powerforge.plugins.json new file mode 100644 index 00000000..663bf11e --- /dev/null +++ b/Module/Examples/PluginCatalog/powerforge.plugins.json @@ -0,0 +1,42 @@ +{ + "$schema": "../../../Schemas/powerforge.plugins.schema.json", + "ProjectRoot": "../../..", + "Configuration": "Release", + "Catalog": [ + { + "Id": "sample-public-plugin", + "ProjectPath": "src/Sample.PublicPlugin/Sample.PublicPlugin.csproj", + "Groups": [ + "public", + "pack-public" + ], + "Framework": "net10.0", + "PackageId": "Sample.PublicPlugin", + "AssemblyName": "Sample.PublicPlugin", + "Manifest": { + "Enabled": true, + "FileName": "plugin.manifest.json", + "EntryTypeMatchBaseType": "IPluginContract", + "Properties": { + "displayName": "{packageId}", + "entryAssembly": "{entryAssembly}", + "entryType": "{entryType}" + } + } + }, + { + "Id": "sample-private-plugin", + "ProjectPath": "src/Sample.PrivatePlugin/Sample.PrivatePlugin.csproj", + "Groups": [ + "private", + "pack-private" + ], + "MsBuildProperties": { + "ExternalRoot": "{projectRoot}" + }, + "Manifest": { + "Enabled": false + } + } + ] +} diff --git a/Module/PSPublishModule.psd1 b/Module/PSPublishModule.psd1 index ed3ce615..71aae4bf 100644 --- a/Module/PSPublishModule.psd1 +++ b/Module/PSPublishModule.psd1 @@ -1,7 +1,7 @@ @{ - AliasesToExport = @('Build-Module', 'Invoke-ModuleBuilder', 'New-PrepareModule') + AliasesToExport = @('Build-Module', 'Connect-Gallery', 'Invoke-ModuleBuilder', 'New-PrepareModule', 'Register-Gallery') Author = 'Przemyslaw Klys' - CmdletsToExport = @('Connect-ModuleRepository', 'Convert-ProjectConsistency', 'Export-CertificateForNuGet', 'Get-MissingFunctions', 'Get-ModuleInformation', 'Get-ModuleTestFailures', 'Get-PowerShellAssemblyMetadata', 'Get-PowerShellCompatibility', 'Get-ProjectConsistency', 'Get-ProjectVersion', 'Install-PrivateModule', 'Invoke-DotNetPublish', 'Invoke-DotNetReleaseBuild', 'Invoke-DotNetRepositoryRelease', 'Invoke-ModuleBuild', 'Invoke-ModuleTestSuite', 'Invoke-ProjectBuild', 'New-ConfigurationArtefact', 'New-ConfigurationBuild', 'New-ConfigurationCommand', 'New-ConfigurationCompatibility', 'New-ConfigurationDelivery', 'New-ConfigurationDocumentation', 'New-ConfigurationDotNetBenchmarkGate', 'New-ConfigurationDotNetBenchmarkMetric', 'New-ConfigurationDotNetConfigBootstrapRule', 'New-ConfigurationDotNetInstaller', 'New-ConfigurationDotNetMatrix', 'New-ConfigurationDotNetMatrixRule', 'New-ConfigurationDotNetProfile', 'New-ConfigurationDotNetProject', 'New-ConfigurationDotNetPublish', 'New-ConfigurationDotNetService', 'New-ConfigurationDotNetServiceLifecycle', 'New-ConfigurationDotNetServiceRecovery', 'New-ConfigurationDotNetSign', 'New-ConfigurationDotNetState', 'New-ConfigurationDotNetStateRule', 'New-ConfigurationDotNetTarget', 'New-ConfigurationExecute', 'New-ConfigurationFileConsistency', 'New-ConfigurationFormat', 'New-ConfigurationImportModule', 'New-ConfigurationInformation', 'New-ConfigurationManifest', 'New-ConfigurationModule', 'New-ConfigurationModuleSkip', 'New-ConfigurationPlaceHolder', 'New-ConfigurationPublish', 'New-ConfigurationTest', 'New-ConfigurationValidation', 'New-DotNetPublishConfig', 'New-ModuleAboutTopic', 'Publish-GitHubReleaseAsset', 'Publish-NugetPackage', 'Register-Certificate', 'Register-ModuleRepository', 'Remove-Comments', 'Remove-ProjectFiles', 'Send-GitHubRelease', 'Set-ProjectVersion', 'Step-Version', 'Update-ModuleRepository', 'Update-PrivateModule') + CmdletsToExport = @('Connect-ModuleRepository', 'Convert-ProjectConsistency', 'Export-CertificateForNuGet', 'Export-ConfigurationProject', 'Get-MissingFunctions', 'Get-ModuleInformation', 'Get-ModuleTestFailures', 'Get-PowerShellAssemblyMetadata', 'Get-PowerShellCompatibility', 'Get-ProjectConsistency', 'Get-ProjectVersion', 'Import-ConfigurationProject', 'Install-PrivateModule', 'Invoke-DotNetPublish', 'Invoke-DotNetReleaseBuild', 'Invoke-DotNetRepositoryRelease', 'Invoke-ModuleBuild', 'Invoke-ModuleTestSuite', 'Invoke-PowerForgeBundlePostProcess', 'Invoke-PowerForgePluginExport', 'Invoke-PowerForgePluginPack', 'Invoke-PowerForgeRelease', 'Invoke-ProjectBuild', 'Invoke-ProjectRelease', 'New-ConfigurationArtefact', 'New-ConfigurationBuild', 'New-ConfigurationCommand', 'New-ConfigurationCompatibility', 'New-ConfigurationDelivery', 'New-ConfigurationDocumentation', 'New-ConfigurationDotNetBenchmarkGate', 'New-ConfigurationDotNetBenchmarkMetric', 'New-ConfigurationDotNetConfigBootstrapRule', 'New-ConfigurationDotNetInstaller', 'New-ConfigurationDotNetMatrix', 'New-ConfigurationDotNetMatrixRule', 'New-ConfigurationDotNetProfile', 'New-ConfigurationDotNetProject', 'New-ConfigurationDotNetPublish', 'New-ConfigurationDotNetService', 'New-ConfigurationDotNetServiceLifecycle', 'New-ConfigurationDotNetServiceRecovery', 'New-ConfigurationDotNetSign', 'New-ConfigurationDotNetState', 'New-ConfigurationDotNetStateRule', 'New-ConfigurationDotNetTarget', 'New-ConfigurationExecute', 'New-ConfigurationFileConsistency', 'New-ConfigurationFormat', 'New-ConfigurationImportModule', 'New-ConfigurationInformation', 'New-ConfigurationManifest', 'New-ConfigurationModule', 'New-ConfigurationModuleSkip', 'New-ConfigurationPlaceHolder', 'New-ConfigurationProject', 'New-ConfigurationProjectInstaller', 'New-ConfigurationProjectOutput', 'New-ConfigurationProjectRelease', 'New-ConfigurationProjectSigning', 'New-ConfigurationProjectTarget', 'New-ConfigurationProjectWorkspace', 'New-ConfigurationPublish', 'New-ConfigurationTest', 'New-ConfigurationValidation', 'New-DotNetPublishConfig', 'New-ModuleAboutTopic', 'New-PowerForgeReleaseConfig', 'New-ProjectReleaseConfig', 'Publish-GitHubReleaseAsset', 'Publish-NugetPackage', 'Register-Certificate', 'Register-ModuleRepository', 'Remove-Comments', 'Remove-ProjectFiles', 'Send-GitHubRelease', 'Set-ProjectVersion', 'Step-Version', 'Update-ModuleRepository', 'Update-PrivateModule') CompanyName = 'Evotec' CompatiblePSEditions = @('Desktop', 'Core') Copyright = '(c) 2011 - 2026 Przemyslaw Klys @ Evotec. All rights reserved.' diff --git a/Module/PSPublishModule.psm1 b/Module/PSPublishModule.psm1 index a432877c..99e3721a 100644 --- a/Module/PSPublishModule.psm1 +++ b/Module/PSPublishModule.psm1 @@ -75,6 +75,6 @@ if (Test-Path -LiteralPath $LibrariesScript) { } $FunctionsToExport = @() -$CmdletsToExport = @('Connect-ModuleRepository', 'Convert-ProjectConsistency', 'Export-CertificateForNuGet', 'Get-MissingFunctions', 'Get-ModuleInformation', 'Get-ModuleTestFailures', 'Get-PowerShellAssemblyMetadata', 'Get-PowerShellCompatibility', 'Get-ProjectConsistency', 'Get-ProjectVersion', 'Install-PrivateModule', 'Invoke-DotNetPublish', 'Invoke-DotNetReleaseBuild', 'Invoke-DotNetRepositoryRelease', 'Invoke-ModuleBuild', 'Invoke-ModuleTestSuite', 'Invoke-ProjectBuild', 'New-ConfigurationArtefact', 'New-ConfigurationBuild', 'New-ConfigurationCommand', 'New-ConfigurationCompatibility', 'New-ConfigurationDelivery', 'New-ConfigurationDocumentation', 'New-ConfigurationDotNetBenchmarkGate', 'New-ConfigurationDotNetBenchmarkMetric', 'New-ConfigurationDotNetConfigBootstrapRule', 'New-ConfigurationDotNetInstaller', 'New-ConfigurationDotNetMatrix', 'New-ConfigurationDotNetMatrixRule', 'New-ConfigurationDotNetProfile', 'New-ConfigurationDotNetProject', 'New-ConfigurationDotNetPublish', 'New-ConfigurationDotNetService', 'New-ConfigurationDotNetServiceLifecycle', 'New-ConfigurationDotNetServiceRecovery', 'New-ConfigurationDotNetSign', 'New-ConfigurationDotNetState', 'New-ConfigurationDotNetStateRule', 'New-ConfigurationDotNetTarget', 'New-ConfigurationExecute', 'New-ConfigurationFileConsistency', 'New-ConfigurationFormat', 'New-ConfigurationImportModule', 'New-ConfigurationInformation', 'New-ConfigurationManifest', 'New-ConfigurationModule', 'New-ConfigurationModuleSkip', 'New-ConfigurationPlaceHolder', 'New-ConfigurationPublish', 'New-ConfigurationTest', 'New-ConfigurationValidation', 'New-DotNetPublishConfig', 'New-ModuleAboutTopic', 'Publish-GitHubReleaseAsset', 'Publish-NugetPackage', 'Register-Certificate', 'Register-ModuleRepository', 'Remove-Comments', 'Remove-ProjectFiles', 'Send-GitHubRelease', 'Set-ProjectVersion', 'Step-Version', 'Update-ModuleRepository', 'Update-PrivateModule') -$AliasesToExport = @('Build-Module', 'Invoke-ModuleBuilder', 'New-PrepareModule') +$CmdletsToExport = @('Connect-ModuleRepository', 'Convert-ProjectConsistency', 'Export-CertificateForNuGet', 'Export-ConfigurationProject', 'Get-MissingFunctions', 'Get-ModuleInformation', 'Get-ModuleTestFailures', 'Get-PowerShellAssemblyMetadata', 'Get-PowerShellCompatibility', 'Get-ProjectConsistency', 'Get-ProjectVersion', 'Import-ConfigurationProject', 'Install-PrivateModule', 'Invoke-DotNetPublish', 'Invoke-DotNetReleaseBuild', 'Invoke-DotNetRepositoryRelease', 'Invoke-ModuleBuild', 'Invoke-ModuleTestSuite', 'Invoke-PowerForgeBundlePostProcess', 'Invoke-PowerForgePluginExport', 'Invoke-PowerForgePluginPack', 'Invoke-PowerForgeRelease', 'Invoke-ProjectBuild', 'Invoke-ProjectRelease', 'New-ConfigurationArtefact', 'New-ConfigurationBuild', 'New-ConfigurationCommand', 'New-ConfigurationCompatibility', 'New-ConfigurationDelivery', 'New-ConfigurationDocumentation', 'New-ConfigurationDotNetBenchmarkGate', 'New-ConfigurationDotNetBenchmarkMetric', 'New-ConfigurationDotNetConfigBootstrapRule', 'New-ConfigurationDotNetInstaller', 'New-ConfigurationDotNetMatrix', 'New-ConfigurationDotNetMatrixRule', 'New-ConfigurationDotNetProfile', 'New-ConfigurationDotNetProject', 'New-ConfigurationDotNetPublish', 'New-ConfigurationDotNetService', 'New-ConfigurationDotNetServiceLifecycle', 'New-ConfigurationDotNetServiceRecovery', 'New-ConfigurationDotNetSign', 'New-ConfigurationDotNetState', 'New-ConfigurationDotNetStateRule', 'New-ConfigurationDotNetTarget', 'New-ConfigurationExecute', 'New-ConfigurationFileConsistency', 'New-ConfigurationFormat', 'New-ConfigurationImportModule', 'New-ConfigurationInformation', 'New-ConfigurationManifest', 'New-ConfigurationModule', 'New-ConfigurationModuleSkip', 'New-ConfigurationPlaceHolder', 'New-ConfigurationProject', 'New-ConfigurationProjectInstaller', 'New-ConfigurationProjectOutput', 'New-ConfigurationProjectRelease', 'New-ConfigurationProjectSigning', 'New-ConfigurationProjectTarget', 'New-ConfigurationProjectWorkspace', 'New-ConfigurationPublish', 'New-ConfigurationTest', 'New-ConfigurationValidation', 'New-DotNetPublishConfig', 'New-ModuleAboutTopic', 'New-PowerForgeReleaseConfig', 'New-ProjectReleaseConfig', 'Publish-GitHubReleaseAsset', 'Publish-NugetPackage', 'Register-Certificate', 'Register-ModuleRepository', 'Remove-Comments', 'Remove-ProjectFiles', 'Send-GitHubRelease', 'Set-ProjectVersion', 'Step-Version', 'Update-ModuleRepository', 'Update-PrivateModule') +$AliasesToExport = @('Build-Module', 'Connect-Gallery', 'Invoke-ModuleBuilder', 'New-PrepareModule', 'Register-Gallery') Export-ModuleMember -Function $FunctionsToExport -Alias $AliasesToExport -Cmdlet $CmdletsToExport diff --git a/PowerForge.Cli/Program.Command.DotNet.cs b/PowerForge.Cli/Program.Command.DotNet.cs index ff711276..17f9ec74 100644 --- a/PowerForge.Cli/Program.Command.DotNet.cs +++ b/PowerForge.Cli/Program.Command.DotNet.cs @@ -94,8 +94,8 @@ private static int CommandDotNet(string[] filteredArgs, CliOptions cli, ILogger var runner = new DotNetPublishPipelineRunner(cmdLogger); if (!string.IsNullOrWhiteSpace(overrideProfile)) spec.Profile = overrideProfile.Trim(); + ApplyDotNetPublishSpecOverrides(spec, overrideTargets, effectiveRids, effectiveFrameworks, effectiveStyles); var plan = runner.Plan(spec, specPath); - ApplyDotNetPublishPlanOverrides(plan, overrideTargets, effectiveRids, effectiveFrameworks, effectiveStyles); ApplyDotNetPublishSkipFlags(plan, skipRestore, skipBuild); if (validateOnly) diff --git a/PowerForge.Cli/Program.Helpers.RunAndParse.cs b/PowerForge.Cli/Program.Helpers.RunAndParse.cs index 229ff1a9..6b5e750f 100644 --- a/PowerForge.Cli/Program.Helpers.RunAndParse.cs +++ b/PowerForge.Cli/Program.Helpers.RunAndParse.cs @@ -422,6 +422,116 @@ static DotNetPublishStyle ParseDotNetPublishStyle(string? value) throw new ArgumentException($"Unknown style: {raw}. Expected one of: {string.Join(", ", Enum.GetNames(typeof(DotNetPublishStyle)))}", nameof(value)); } + static void ApplyDotNetPublishSpecOverrides( + DotNetPublishSpec spec, + string[] overrideTargets, + string[] overrideRids, + string[] overrideFrameworks, + DotNetPublishStyle[] overrideStyles) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var targets = (spec.Targets ?? Array.Empty()) + .Where(t => t is not null) + .ToArray(); + var selectedTargetNames = NormalizeCliStringSet(overrideTargets); + var activeProfile = GetActiveDotNetPublishProfile(spec); + + if (selectedTargetNames.Length > 0) + { + var missing = selectedTargetNames + .Where(n => targets.All(t => !t.Name.Equals(n, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + if (missing.Length > 0) + throw new ArgumentException($"Unknown target(s): {string.Join(", ", missing)}", nameof(overrideTargets)); + + if (activeProfile is not null) + { + activeProfile.Targets = selectedTargetNames; + } + else + { + spec.Targets = targets + .Where(t => selectedTargetNames.Contains(t.Name, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + targets = spec.Targets; + } + } + + var runtimes = NormalizeCliStringSet(overrideRids); + if (runtimes.Length > 0) + { + if (activeProfile is not null) + activeProfile.Runtimes = runtimes; + + foreach (var target in targets) + { + if (target.Publish is null) continue; + target.Publish.Runtimes = runtimes; + } + } + + var frameworks = NormalizeCliStringSet(overrideFrameworks); + if (frameworks.Length > 0) + { + if (activeProfile is not null) + activeProfile.Frameworks = frameworks; + + foreach (var target in targets) + { + if (target.Publish is null) continue; + target.Publish.Framework = frameworks[0]; + target.Publish.Frameworks = frameworks; + } + } + + var styles = (overrideStyles ?? Array.Empty()) + .Distinct() + .ToArray(); + if (styles.Length > 0) + { + if (activeProfile is not null) + activeProfile.Style = styles.Length == 1 ? styles[0] : null; + + foreach (var target in targets) + { + if (target.Publish is null) continue; + target.Publish.Style = styles[0]; + target.Publish.Styles = styles; + } + } + } + + static DotNetPublishProfile? GetActiveDotNetPublishProfile(DotNetPublishSpec spec) + { + var profiles = (spec.Profiles ?? Array.Empty()) + .Where(p => p is not null && !string.IsNullOrWhiteSpace(p.Name)) + .ToArray(); + if (profiles.Length == 0) + return null; + + var profileName = !string.IsNullOrWhiteSpace(spec.Profile) + ? spec.Profile!.Trim() + : profiles.FirstOrDefault(p => p.Default)?.Name; + if (string.IsNullOrWhiteSpace(profileName)) + return null; + + var profile = profiles.FirstOrDefault(p => p.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase)); + if (profile is null) + throw new ArgumentException($"Profile '{profileName}' was not found.", nameof(spec)); + + return profile; + } + + static string[] NormalizeCliStringSet(IEnumerable? values) + { + return (values ?? Array.Empty()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + static void ApplyDotNetPublishPlanOverrides( DotNetPublishPlan plan, string[] overrideTargets, diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs index a75e5d42..7ac290ac 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs @@ -2,12 +2,36 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; using Xunit; namespace PowerForge.Tests; public sealed class DotNetPublishPipelineRunnerBundleTests { + [Fact] + public void ExamplePackageBundleMsi_DeserializesPackageLayoutPrimitives() + { + var repoRoot = FindRepoRoot(); + var examplePath = Path.Combine(repoRoot, "Module", "Examples", "DotNetPublish", "Example.PackageBundleMsi.json"); + + Assert.True(File.Exists(examplePath), $"Example file not found: {examplePath}"); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + options.Converters.Add(new JsonStringEnumConverter()); + var spec = JsonSerializer.Deserialize(File.ReadAllText(examplePath), options); + + Assert.NotNull(spec); + var bundle = Assert.Single(spec.Bundles); + Assert.Equal("package", bundle.Id); + Assert.Equal("Service", bundle.PrimarySubdirectory); + Assert.Single(bundle.CopyItems); + Assert.Single(bundle.ModuleIncludes); + Assert.Single(bundle.GeneratedScripts); + Assert.Equal("package", Assert.Single(spec.Installers).PrepareFromBundleId); + } + [Fact] public void Plan_BindsInstallerPrepareFromBundleId_ToMsiPrepareStep() { @@ -47,6 +71,81 @@ public void Plan_BindsInstallerPrepareFromBundleId_ToMsiPrepareStep() } } + [Fact] + public void Plan_NormalizesBundlePackageLayoutPrimitives() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + + var spec = CreateBaseSpec(root, app); + spec.Profile = "release"; + spec.Profiles = new[] + { + new DotNetPublishProfile + { + Name = "release", + Default = true, + Targets = new[] { "app" } + } + }; + spec.Bundles = new[] + { + new DotNetPublishBundle + { + Id = "package", + PrepareFromTarget = "app", + PrimarySubdirectory = " Service ", + CopyItems = new[] + { + new DotNetPublishBundleCopyItem + { + SourcePath = " Build/README.md ", + DestinationPath = " README.md " + } + }, + ModuleIncludes = new[] + { + new DotNetPublishBundleModuleInclude + { + ModuleName = " PowerTierBridge ", + SourcePath = " Artifacts/Modules/{moduleName} " + } + }, + GeneratedScripts = new[] + { + new DotNetPublishBundleGeneratedScript + { + Template = "{{CommandName}}", + OutputPath = " Scripts/Install-Service.ps1 ", + Tokens = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CommandName"] = "Install-TierBridgeService" + } + } + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var bundle = Assert.Single(plan.Bundles); + + Assert.Equal("Build/README.md", bundle.CopyItems[0].SourcePath); + Assert.Equal("README.md", bundle.CopyItems[0].DestinationPath); + Assert.Equal("Service", bundle.PrimarySubdirectory); + Assert.Equal("PowerTierBridge", bundle.ModuleIncludes[0].ModuleName); + Assert.Equal("Artifacts/Modules/{moduleName}", bundle.ModuleIncludes[0].SourcePath); + Assert.Equal("Modules/{moduleName}", bundle.ModuleIncludes[0].DestinationPath); + Assert.Equal("Scripts/Install-Service.ps1", bundle.GeneratedScripts[0].OutputPath); + Assert.Equal("Install-TierBridgeService", bundle.GeneratedScripts[0].Tokens["CommandName"]); + } + finally + { + TryDelete(root); + } + } + [Fact] public void BuildBundle_AppliesPostProcessArchiveDeleteAndMetadata() { @@ -158,6 +257,176 @@ public void BuildBundle_AppliesPostProcessArchiveDeleteAndMetadata() } } + [Fact] + public void BuildBundle_CopiesPackageItemsModulesAndGeneratedScripts() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var readmePath = Path.Combine(root, "Build", "README.package.md"); + Directory.CreateDirectory(Path.GetDirectoryName(readmePath)!); + File.WriteAllText(readmePath, "# Package"); + + var moduleRoot = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Modules", "PowerTierBridge")).FullName; + File.WriteAllText(Path.Combine(moduleRoot, "PowerTierBridge.psd1"), "@{}"); + File.WriteAllText(Path.Combine(moduleRoot, "PowerTierBridge.psm1"), ""); + + var plan = new DotNetPublishPlan + { + ProjectRoot = root, + Bundles = new[] + { + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + PrimarySubdirectory = "Service", + CopyItems = new[] + { + new DotNetPublishBundleCopyItemPlan + { + SourcePath = "Build/README.package.md", + DestinationPath = "README.md" + } + }, + ModuleIncludes = new[] + { + new DotNetPublishBundleModuleIncludePlan + { + ModuleName = "PowerTierBridge", + SourcePath = "Artifacts/Modules/{moduleName}", + DestinationPath = "Modules/{moduleName}" + } + }, + GeneratedScripts = new[] + { + new DotNetPublishBundleGeneratedScriptPlan + { + Template = "Import-Module \"$PSScriptRoot\\Modules\\{{ModuleName}}\\{{ModuleName}}.psd1\" -Force\r\n{{CommandName}}\r\n", + OutputPath = "Install-Service.ps1", + Tokens = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ModuleName"] = "PowerTierBridge", + ["CommandName"] = "Install-TierBridgeService" + } + } + } + } + } + }; + + var artefacts = new[] + { + new DotNetPublishArtefactResult + { + Category = DotNetPublishArtefactCategory.Publish, + Target = "app", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.PortableCompat, + OutputDir = publishDir, + PublishDir = publishDir + } + }; + + var outputDir = Path.Combine(root, "Artifacts", "Bundles", "package"); + var step = new DotNetPublishStep + { + Key = "bundle:package:app:net10.0:win-x64:PortableCompat", + Kind = DotNetPublishStepKind.Bundle, + BundleId = "package", + TargetName = "app", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.PortableCompat, + BundleOutputPath = outputDir + }; + + var result = InvokeBuildBundle(plan, artefacts, step); + + Assert.NotNull(result); + Assert.True(File.Exists(Path.Combine(outputDir, "Service", "App.exe"))); + Assert.False(File.Exists(Path.Combine(outputDir, "App.exe"))); + Assert.True(File.Exists(Path.Combine(outputDir, "README.md"))); + Assert.True(File.Exists(Path.Combine(outputDir, "Modules", "PowerTierBridge", "PowerTierBridge.psd1"))); + + var generatedScript = Path.Combine(outputDir, "Install-Service.ps1"); + Assert.True(File.Exists(generatedScript)); + var script = File.ReadAllText(generatedScript); + Assert.Contains("PowerTierBridge.psd1", script, StringComparison.Ordinal); + Assert.Contains("Install-TierBridgeService", script, StringComparison.Ordinal); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Plan_PublishesBundleIncludesBeforeBundleStep() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var cli = CreateProject(root, "Cli/Cli.csproj"); + + var spec = CreateBaseSpec(root, app); + spec.Targets = new[] + { + spec.Targets[0], + new DotNetPublishTarget + { + Name = "cli", + ProjectPath = cli, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.AotSpeed }, + UseStaging = false + } + } + }; + spec.Bundles = new[] + { + new DotNetPublishBundle + { + Id = "package", + PrepareFromTarget = "app", + Includes = new[] + { + new DotNetPublishBundleInclude + { + Target = "cli", + Style = DotNetPublishStyle.AotSpeed, + Subdirectory = "CLI" + } + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var keys = plan.Steps.Select(step => step.Key).ToArray(); + + var cliPublish = Array.FindIndex(keys, key => key.StartsWith("publish:cli:", StringComparison.OrdinalIgnoreCase)); + var appPublish = Array.FindIndex(keys, key => key.StartsWith("publish:app:", StringComparison.OrdinalIgnoreCase)); + var bundle = Array.FindIndex(keys, key => key.StartsWith("bundle:package:", StringComparison.OrdinalIgnoreCase)); + + Assert.True(cliPublish >= 0); + Assert.True(appPublish >= 0); + Assert.True(bundle > appPublish); + Assert.True(bundle > cliPublish); + } + finally + { + TryDelete(root); + } + } + [Fact] public void BuildBundle_RejectsPostProcessPathsOutsideBundleRoot() { @@ -372,6 +641,20 @@ private static string CreateTempRoot() return root; } + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var i = 0; i < 12 && current is not null; i++) + { + var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); + if (File.Exists(marker)) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("Unable to locate repository root for dotnet publish bundle tests."); + } + private static void TryDelete(string path) { try diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs new file mode 100644 index 00000000..1d832789 --- /dev/null +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs @@ -0,0 +1,173 @@ +namespace PowerForge.Tests; + +public sealed class DotNetPublishPipelineRunnerHookTests +{ + [Fact] + public void Plan_AddsCommandHooksAroundPublishAndBundleSteps() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var spec = new DotNetPublishSpec + { + Profile = "release", + Profiles = new[] + { + new DotNetPublishProfile + { + Name = "release", + Default = true, + Targets = new[] { "App" } + } + }, + DotNet = new DotNetPublishDotNetOptions + { + ProjectRoot = root, + Restore = false, + Build = false, + Runtimes = new[] { "win-x64" } + }, + Targets = new[] + { + new DotNetPublishTarget + { + Name = "App", + ProjectPath = app, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + } + }, + Bundles = new[] + { + new DotNetPublishBundle + { + Id = "portable", + PrepareFromTarget = "App" + } + }, + Hooks = new[] + { + new DotNetPublishCommandHook + { + Id = "sync-catalog", + Phase = DotNetPublishCommandHookPhase.BeforeTargetPublish, + Command = "pwsh", + Arguments = new[] { "-NoProfile", "-Command", "exit 0" }, + Targets = new[] { "App" } + }, + new DotNetPublishCommandHook + { + Id = "bundle-summary", + Phase = DotNetPublishCommandHookPhase.AfterBundle, + Command = "pwsh", + Arguments = new[] { "-NoProfile", "-Command", "exit 0" } + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var keys = plan.Steps.Select(step => step.Key).ToArray(); + + var beforePublish = Array.FindIndex(keys, key => key.StartsWith("hook:BeforeTargetPublish:sync-catalog", StringComparison.Ordinal)); + var publish = Array.FindIndex(keys, key => key.StartsWith("publish:App:", StringComparison.Ordinal)); + var bundle = Array.FindIndex(keys, key => key.StartsWith("bundle:portable:", StringComparison.Ordinal)); + var afterBundle = Array.FindIndex(keys, key => key.StartsWith("hook:AfterBundle:bundle-summary", StringComparison.Ordinal)); + + Assert.True(beforePublish >= 0); + Assert.True(publish > beforePublish); + Assert.True(bundle > publish); + Assert.True(afterBundle > bundle); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void RunCommandHook_ExpandsArgumentsWorkingDirectoryAndEnvironment() + { + var root = CreateTempRoot(); + try + { + var outputPath = Path.Combine(root, "hook-output.txt"); + var step = new DotNetPublishStep + { + Key = "hook:BeforeBuild:write", + Kind = DotNetPublishStepKind.CommandHook, + HookId = "write", + HookPhase = DotNetPublishCommandHookPhase.BeforeBuild, + HookCommand = "pwsh", + HookArguments = new[] + { + "-NoLogo", + "-NoProfile", + "-Command", + "$value = \"target={0};rid={1};phase=$env:PF_HOOK_PHASE\" -f '{target}', '{rid}'; Set-Content -LiteralPath $env:PF_HOOK_OUTPUT -Value $value" + }, + HookEnvironment = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["PF_HOOK_OUTPUT"] = outputPath, + ["PF_HOOK_PHASE"] = "{phase}" + }, + TargetName = "App", + Runtime = "win-x64", + Framework = "net10.0", + Style = DotNetPublishStyle.PortableCompat, + HookTimeoutSeconds = 30, + HookRequired = true + }; + + new DotNetPublishPipelineRunner(new NullLogger()).RunCommandHook( + new DotNetPublishPlan + { + ProjectRoot = root, + Configuration = "Release" + }, + step); + + Assert.True(File.Exists(outputPath)); + var output = File.ReadAllText(outputPath); + Assert.Contains("target=App", output, StringComparison.Ordinal); + Assert.Contains("rid=win-x64", output, StringComparison.Ordinal); + Assert.Contains("phase=BeforeBuild", output, StringComparison.Ordinal); + } + finally + { + TryDelete(root); + } + } + + private static string CreateProject(string root, string relativePath) + { + var fullPath = Path.Combine(root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, ""); + return fullPath; + } + + private static string CreateTempRoot() + { + var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + // best effort + } + } +} diff --git a/PowerForge.Tests/ExportDetectorTests.cs b/PowerForge.Tests/ExportDetectorTests.cs index c416ac1e..e8e33902 100644 --- a/PowerForge.Tests/ExportDetectorTests.cs +++ b/PowerForge.Tests/ExportDetectorTests.cs @@ -1,5 +1,6 @@ using System.Management.Automation; using System.Text; +using PSPublishModule; namespace PowerForge.Tests; @@ -24,6 +25,35 @@ public void DetectBinaryAliases_finds_AliasAttribute() Assert.Contains("Get-ExampleAlias", aliases); } + [Fact] + public void DetectBinaryCmdlets_finds_PSPublishModule_plugin_and_bundle_cmdlets() + { + var path = typeof(InvokePowerForgePluginExportCommand).Assembly.Location; + var cmdlets = BinaryExportDetector.DetectBinaryCmdlets(new[] { path }); + + Assert.Contains("Invoke-PowerForgeBundlePostProcess", cmdlets); + Assert.Contains("Invoke-PowerForgePluginExport", cmdlets); + Assert.Contains("Invoke-PowerForgePluginPack", cmdlets); + } + + [Fact] + public void GeneratedModuleFiles_export_PSPublishModule_plugin_and_bundle_cmdlets() + { + var repoRoot = FindRepoRoot(); + var manifestPath = Path.Combine(repoRoot, "Module", "PSPublishModule.psd1"); + var bootstrapperPath = Path.Combine(repoRoot, "Module", "PSPublishModule.psm1"); + + var exports = ModuleManifestExportReader.ReadExports(manifestPath); + var bootstrapper = File.ReadAllText(bootstrapperPath); + + Assert.Contains("Invoke-PowerForgeBundlePostProcess", exports.Cmdlets); + Assert.Contains("Invoke-PowerForgePluginExport", exports.Cmdlets); + Assert.Contains("Invoke-PowerForgePluginPack", exports.Cmdlets); + Assert.Contains("Invoke-PowerForgeBundlePostProcess", bootstrapper, StringComparison.Ordinal); + Assert.Contains("Invoke-PowerForgePluginExport", bootstrapper, StringComparison.Ordinal); + Assert.Contains("Invoke-PowerForgePluginPack", bootstrapper, StringComparison.Ordinal); + } + [Fact] public void DetectScriptFunctions_ignores_nested_helper_functions() { @@ -58,4 +88,18 @@ function Write-DeliveryError { private sealed class GetExampleCommand : PSCmdlet { } + + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var i = 0; i < 12 && current is not null; i++) + { + var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); + if (File.Exists(marker)) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("Unable to locate repository root for export detector tests."); + } } diff --git a/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs b/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs index 507f29b9..9d376249 100644 --- a/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs +++ b/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs @@ -6,6 +6,36 @@ namespace PowerForge.Tests; public sealed class PowerForgePluginCatalogServiceTests { + [Fact] + public void ExampleCatalog_deserializes_to_plugin_catalog_spec() + { + var repoRoot = FindRepoRoot(); + var examplePath = Path.Combine(repoRoot, "Module", "Examples", "PluginCatalog", "powerforge.plugins.json"); + + Assert.True(File.Exists(examplePath), $"Plugin catalog example not found: {examplePath}"); + + var spec = JsonSerializer.Deserialize( + File.ReadAllText(examplePath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(spec); + Assert.Equal("Release", spec.Configuration); + Assert.Equal("../../..", spec.ProjectRoot); + Assert.Collection( + spec.Catalog, + first => + { + Assert.Equal("sample-public-plugin", first.Id); + Assert.Contains("public", first.Groups); + Assert.Equal("plugin.manifest.json", first.Manifest?.FileName); + }, + second => + { + Assert.Equal("sample-private-plugin", second.Id); + Assert.False(second.Manifest?.Enabled); + }); + } + [Fact] public void PlanFolderExport_SelectsGroupsAndResolvesPreferredFramework() { @@ -447,4 +477,18 @@ private static void TryDelete(string path) else if (File.Exists(path)) File.Delete(path); } + + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var i = 0; i < 12 && current is not null; i++) + { + var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); + if (File.Exists(marker)) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("Unable to locate repository root for plugin catalog tests."); + } } diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishEnums.cs b/PowerForge/Models/DotNetPublish/DotNetPublishEnums.cs index 0c0e1c31..ca0ac856 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishEnums.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishEnums.cs @@ -143,11 +143,32 @@ public enum DotNetPublishBaselineMode Update } +/// +/// Phase where a configured command hook runs within the dotnet publish pipeline. +/// +public enum DotNetPublishCommandHookPhase +{ + /// Run before restore steps. + BeforeRestore, + /// Run before build steps. + BeforeBuild, + /// Run before each matching target publish step. + BeforeTargetPublish, + /// Run after each matching target publish step. + AfterTargetPublish, + /// Run before each matching bundle step. + BeforeBundle, + /// Run after each matching bundle step. + AfterBundle +} + /// /// Kind of step executed by the dotnet publish pipeline. /// public enum DotNetPublishStepKind { + /// Run a configured command hook. + CommandHook, /// Restore NuGet packages. Restore, /// Clean build outputs. diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs b/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs index 269469c5..35c61c67 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs @@ -233,6 +233,9 @@ public sealed class DotNetPublishBundlePlan /// Optional resolved bundle output path template. public string? OutputPath { get; set; } + /// Optional bundle subdirectory for the primary source target. + public string? PrimarySubdirectory { get; set; } + /// When true, clears the bundle output directory before composition. public bool ClearOutput { get; set; } = true; @@ -248,6 +251,15 @@ public sealed class DotNetPublishBundlePlan /// Additional published target includes. public DotNetPublishBundleIncludePlan[] Includes { get; set; } = Array.Empty(); + /// Additional file or directory copy items. + public DotNetPublishBundleCopyItemPlan[] CopyItems { get; set; } = Array.Empty(); + + /// PowerShell module payloads copied into the bundle. + public DotNetPublishBundleModuleIncludePlan[] ModuleIncludes { get; set; } = Array.Empty(); + + /// Scripts generated from templates into the bundle. + public DotNetPublishBundleGeneratedScriptPlan[] GeneratedScripts { get; set; } = Array.Empty(); + /// Bundle post-copy scripts. public DotNetPublishBundleScriptPlan[] Scripts { get; set; } = Array.Empty(); @@ -279,6 +291,69 @@ public sealed class DotNetPublishBundleIncludePlan public bool Required { get; set; } = true; } +/// +/// Resolved file or directory copy item for a bundle. +/// +public sealed class DotNetPublishBundleCopyItemPlan +{ + /// Source file or directory path template. + public string SourcePath { get; set; } = string.Empty; + + /// Destination path template under the bundle output root. + public string DestinationPath { get; set; } = string.Empty; + + /// When true, missing sources fail the bundle step. + public bool Required { get; set; } = true; + + /// When true, clears an existing destination before copy. + public bool ClearDestination { get; set; } = true; +} + +/// +/// Resolved PowerShell module payload include for a bundle. +/// +public sealed class DotNetPublishBundleModuleIncludePlan +{ + /// Logical module name. + public string ModuleName { get; set; } = string.Empty; + + /// Source module directory path template. + public string SourcePath { get; set; } = string.Empty; + + /// Destination path template under the bundle output root. + public string DestinationPath { get; set; } = string.Empty; + + /// When true, missing sources fail the bundle step. + public bool Required { get; set; } = true; + + /// When true, clears an existing destination before copy. + public bool ClearDestination { get; set; } = true; +} + +/// +/// Resolved template-generated script for a bundle. +/// +public sealed class DotNetPublishBundleGeneratedScriptPlan +{ + /// Optional template file path template. + public string? TemplatePath { get; set; } + + /// Optional inline template content. + public string? Template { get; set; } + + /// Output path template under the bundle output root. + public string OutputPath { get; set; } = string.Empty; + + /// Template token values. + public Dictionary Tokens { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// When true, replaces an existing output file. + public bool Overwrite { get; set; } = true; + + /// Optional signing options for the generated script. + public DotNetPublishSignOptions? Sign { get; set; } +} + /// /// Resolved PowerShell hook for bundle composition. /// @@ -436,4 +511,28 @@ public sealed class DotNetPublishStep /// Optional resolved Store package output path for Store build steps. public string? StorePackageOutputPath { get; set; } + + /// Optional command hook identifier. + public string? HookId { get; set; } + + /// Optional command hook phase. + public DotNetPublishCommandHookPhase? HookPhase { get; set; } + + /// Optional command hook executable or script. + public string? HookCommand { get; set; } + + /// Optional command hook arguments. + public string[] HookArguments { get; set; } = Array.Empty(); + + /// Optional command hook working directory. + public string? HookWorkingDirectory { get; set; } + + /// Optional command hook environment variables. + public Dictionary HookEnvironment { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// Optional command hook timeout in seconds. + public int HookTimeoutSeconds { get; set; } = 600; + + /// When true, command hook failures fail the publish run. + public bool HookRequired { get; set; } = true; } diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs index 10a82383..ceb5cd6f 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs @@ -72,6 +72,11 @@ public sealed class DotNetPublishSpec /// public DotNetPublishBenchmarkGate[] BenchmarkGates { get; set; } = Array.Empty(); + /// + /// Optional command hooks executed at fixed publish phases. + /// + public DotNetPublishCommandHook[] Hooks { get; set; } = Array.Empty(); + /// /// Optional manifest output configuration. /// @@ -329,6 +334,12 @@ public sealed class DotNetPublishBundle /// public string? OutputPath { get; set; } + /// + /// Optional subdirectory under the bundle output root where the primary publish target should be copied. + /// When omitted, the primary target is copied into the bundle root. + /// + public string? PrimarySubdirectory { get; set; } + /// /// When true, clears the bundle output directory before composing files. Default: true. /// @@ -355,6 +366,21 @@ public sealed class DotNetPublishBundle /// public DotNetPublishBundleInclude[] Includes { get; set; } = Array.Empty(); + /// + /// Optional file or directory items copied into the bundle after publish-target includes. + /// + public DotNetPublishBundleCopyItem[] CopyItems { get; set; } = Array.Empty(); + + /// + /// Optional built PowerShell module payloads copied into the bundle, usually under Modules/{moduleName}. + /// + public DotNetPublishBundleModuleInclude[] ModuleIncludes { get; set; } = Array.Empty(); + + /// + /// Optional scripts generated from templates into the bundle after copy operations. + /// + public DotNetPublishBundleGeneratedScript[] GeneratedScripts { get; set; } = Array.Empty(); + /// /// Optional PowerShell scripts executed after the bundle contents are copied. /// @@ -401,6 +427,75 @@ public sealed class DotNetPublishBundleInclude public bool Required { get; set; } = true; } +/// +/// File or directory copied into a bundle from a path outside the publish target output. +/// +public sealed class DotNetPublishBundleCopyItem +{ + /// Source file or directory path. Relative paths resolve against project root and support bundle tokens. + public string SourcePath { get; set; } = string.Empty; + + /// Destination path under the bundle output root. Supports bundle tokens. + public string DestinationPath { get; set; } = string.Empty; + + /// When true, missing sources fail the bundle step. Default: true. + public bool Required { get; set; } = true; + + /// When true, clears an existing destination file/directory before copy. Default: true. + public bool ClearDestination { get; set; } = true; +} + +/// +/// Built PowerShell module payload copied into a bundle. +/// +public sealed class DotNetPublishBundleModuleInclude +{ + /// Logical module name. Used by default destination and template tokens. + public string ModuleName { get; set; } = string.Empty; + + /// Source module directory path, preferably a PowerForge module build artefact output. + public string SourcePath { get; set; } = string.Empty; + + /// Optional destination path under the bundle output root. Default: Modules/{moduleName}. + public string? DestinationPath { get; set; } + + /// When true, missing sources fail the bundle step. Default: true. + public bool Required { get; set; } = true; + + /// When true, clears an existing destination directory before copy. Default: true. + public bool ClearDestination { get; set; } = true; +} + +/// +/// Script generated from inline or file-based template content into a bundle. +/// +public sealed class DotNetPublishBundleGeneratedScript +{ + /// Optional template file path resolved relative to project root. Supports bundle tokens. + public string? TemplatePath { get; set; } + + /// Optional inline template content. Used when is not set. + public string? Template { get; set; } + + /// Output path under the bundle output root. Supports bundle tokens. + public string OutputPath { get; set; } = string.Empty; + + /// Template token values. Values support bundle tokens before template rendering. + public Dictionary? Tokens { get; set; } + + /// When true, replaces an existing output file. Default: true. + public bool Overwrite { get; set; } = true; + + /// Optional named signing profile reference used when is not set. + public string? SignProfile { get; set; } + + /// Optional signing options for the generated script. + public DotNetPublishSignOptions? Sign { get; set; } + + /// Optional partial overrides applied on top of . + public DotNetPublishSignPatch? SignOverrides { get; set; } +} + /// /// PowerShell script executed as part of bundle composition. /// @@ -675,6 +770,48 @@ public sealed class DotNetPublishBenchmarkMetric public bool Required { get; set; } = true; } +/// +/// Command hook executed at a fixed phase of the dotnet publish pipeline. +/// +public sealed class DotNetPublishCommandHook +{ + /// Stable hook identifier used in plan step keys. + public string Id { get; set; } = string.Empty; + + /// Pipeline phase where the hook runs. + public DotNetPublishCommandHookPhase Phase { get; set; } + + /// Executable or script command. Relative paths resolve against project root and support hook tokens. + public string Command { get; set; } = string.Empty; + + /// Command arguments. Values support hook tokens. + public string[] Arguments { get; set; } = Array.Empty(); + + /// Optional working directory. Relative paths resolve against project root and support hook tokens. + public string? WorkingDirectory { get; set; } + + /// Optional environment variables. Values support hook tokens. + public Dictionary? Environment { get; set; } + + /// Maximum command execution time in seconds. Default: 600. + public int TimeoutSeconds { get; set; } = 600; + + /// When true, a non-zero exit code fails the publish run. Default: true. + public bool Required { get; set; } = true; + + /// Optional target-name filter for target/bundle phases. + public string[] Targets { get; set; } = Array.Empty(); + + /// Optional runtime filter for target/bundle phases. + public string[] Runtimes { get; set; } = Array.Empty(); + + /// Optional framework filter for target/bundle phases. + public string[] Frameworks { get; set; } = Array.Empty(); + + /// Optional publish-style filter for target/bundle phases. + public DotNetPublishStyle[] Styles { get; set; } = Array.Empty(); +} + /// /// Global matrix defaults and filters for target expansion. /// diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs index ad246741..425f9fa5 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs @@ -1,4 +1,5 @@ using System.IO.Compression; +using System.Text; namespace PowerForge; @@ -59,7 +60,12 @@ internal DotNetPublishArtefactResult BuildBundle( } Directory.CreateDirectory(outputDir); - DirectoryCopy(sourceArtefact.OutputDir, outputDir); + var primaryDestination = string.IsNullOrWhiteSpace(bundle.PrimarySubdirectory) + ? outputDir + : ResolvePath(outputDir, bundle.PrimarySubdirectory!); + EnsurePathWithinRoot(outputDir, primaryDestination, $"Bundle '{bundleId}' primary destination"); + Directory.CreateDirectory(primaryDestination); + DirectoryCopy(sourceArtefact.OutputDir, primaryDestination); foreach (var include in bundle.Includes ?? Array.Empty()) { @@ -106,6 +112,7 @@ internal DotNetPublishArtefactResult BuildBundle( ["projectRoot"] = plan.ProjectRoot, ["output"] = outputDir, ["sourceOutput"] = sourceArtefact.OutputDir, + ["primaryOutput"] = primaryDestination, ["zip"] = step.BundleZipPath ?? string.Empty }; @@ -115,6 +122,9 @@ internal DotNetPublishArtefactResult BuildBundle( tokens["keepDocs"] = (sourceTargetPlan?.Publish?.KeepDocs ?? false).ToString(); tokens["signEnabled"] = (sourceTargetPlan?.Publish?.Sign?.Enabled ?? false).ToString(); + CopyBundleItems(plan, bundle, outputDir, tokens); + CopyBundleModules(plan, bundle, outputDir, tokens); + GenerateBundleScripts(plan, bundle, outputDir, tokens); RunBundleScripts(plan, bundle, tokens); if (bundle.PostProcess is not null) { @@ -213,6 +223,132 @@ private void RunBundleScripts( } } + private void CopyBundleItems( + DotNetPublishPlan plan, + DotNetPublishBundlePlan bundle, + string outputDir, + IReadOnlyDictionary tokens) + { + foreach (var item in bundle.CopyItems ?? Array.Empty()) + { + if (item is null) continue; + + var source = ResolvePath(plan.ProjectRoot, ApplyTemplate(item.SourcePath, tokens)); + var destination = ResolvePath(outputDir, ApplyTemplate(item.DestinationPath, tokens)); + EnsurePathWithinRoot(outputDir, destination, $"Bundle '{bundle.Id}' copy destination"); + + CopyBundlePath(source, destination, item.Required, item.ClearDestination, $"Bundle '{bundle.Id}' copy item"); + } + } + + private void CopyBundleModules( + DotNetPublishPlan plan, + DotNetPublishBundlePlan bundle, + string outputDir, + IReadOnlyDictionary tokens) + { + foreach (var module in bundle.ModuleIncludes ?? Array.Empty()) + { + if (module is null) continue; + + var moduleTokens = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var token in tokens) + moduleTokens[token.Key] = token.Value ?? string.Empty; + moduleTokens["moduleName"] = module.ModuleName; + var source = ResolvePath(plan.ProjectRoot, ApplyTemplate(module.SourcePath, moduleTokens)); + var destination = ResolvePath(outputDir, ApplyTemplate(module.DestinationPath, moduleTokens)); + EnsurePathWithinRoot(outputDir, destination, $"Bundle '{bundle.Id}' module include destination"); + + CopyBundlePath(source, destination, module.Required, module.ClearDestination, $"Bundle '{bundle.Id}' module include '{module.ModuleName}'"); + } + } + + private void GenerateBundleScripts( + DotNetPublishPlan plan, + DotNetPublishBundlePlan bundle, + string outputDir, + IReadOnlyDictionary tokens) + { + foreach (var script in bundle.GeneratedScripts ?? Array.Empty()) + { + if (script is null) continue; + + var outputPath = ResolvePath(outputDir, ApplyTemplate(script.OutputPath, tokens)); + EnsurePathWithinRoot(outputDir, outputPath, $"Bundle '{bundle.Id}' generated script output path"); + if (File.Exists(outputPath) && !script.Overwrite) + throw new IOException($"Generated script already exists and Overwrite=false: {outputPath}"); + + var templateName = script.TemplatePath ?? script.OutputPath; + var template = script.Template; + if (!string.IsNullOrWhiteSpace(script.TemplatePath)) + { + var templatePath = ResolvePath(plan.ProjectRoot, ApplyTemplate(script.TemplatePath!, tokens)); + if (!File.Exists(templatePath)) + throw new FileNotFoundException($"Generated script template not found for bundle '{bundle.Id}': {templatePath}", templatePath); + templateName = templatePath; + template = File.ReadAllText(templatePath, Encoding.UTF8); + } + + var renderTokens = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var token in tokens) + renderTokens[token.Key] = token.Value ?? string.Empty; + foreach (var token in script.Tokens ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) + renderTokens[token.Key] = ApplyTemplate(token.Value ?? string.Empty, tokens); + + var rendered = ScriptTemplateRenderer.Render(templateName ?? "bundle generated script", template ?? string.Empty, renderTokens); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + File.WriteAllText(outputPath, rendered, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + _logger.Info($"Generated bundle script -> {FrameworkCompatibility.GetRelativePath(outputDir, outputPath)} ({bundle.Id})"); + if (script.Sign is not null && script.Sign.Enabled) + _ = TrySignFiles(new[] { outputPath }, outputDir, script.Sign, scope: $"bundle '{bundle.Id}' generated scripts"); + } + } + + private void CopyBundlePath( + string source, + string destination, + bool required, + bool clearDestination, + string description) + { + source = Path.GetFullPath(source); + destination = Path.GetFullPath(destination); + + if (Directory.Exists(source)) + { + if (clearDestination && Directory.Exists(destination)) + Directory.Delete(destination, recursive: true); + else if (clearDestination && File.Exists(destination)) + File.Delete(destination); + + DirectoryCopy(source, destination); + return; + } + + if (File.Exists(source)) + { + if (clearDestination && Directory.Exists(destination)) + Directory.Delete(destination, recursive: true); + + var destinationFile = destination; + if (Directory.Exists(destinationFile)) + destinationFile = Path.Combine(destinationFile, Path.GetFileName(source)); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + if (clearDestination && File.Exists(destinationFile)) + File.Delete(destinationFile); + File.Copy(source, destinationFile, overwrite: clearDestination); + return; + } + + var message = $"{description} source not found: {source}"; + if (required) + throw new FileNotFoundException(message, source); + + _logger.Warn(message); + } + private string? CreateBundleZip( DotNetPublishPlan plan, DotNetPublishBundlePlan bundle, diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs new file mode 100644 index 00000000..0d0365cf --- /dev/null +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; + +namespace PowerForge; + +public sealed partial class DotNetPublishPipelineRunner +{ + internal void RunCommandHook(DotNetPublishPlan plan, DotNetPublishStep step) + { + if (plan is null) throw new ArgumentNullException(nameof(plan)); + if (step is null) throw new ArgumentNullException(nameof(step)); + if (string.IsNullOrWhiteSpace(step.HookCommand)) + throw new InvalidOperationException($"Hook step '{step.Key}' is missing HookCommand."); + + var tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["hook"] = step.HookId ?? string.Empty, + ["phase"] = step.HookPhase?.ToString() ?? string.Empty, + ["target"] = step.TargetName ?? string.Empty, + ["rid"] = step.Runtime ?? string.Empty, + ["framework"] = step.Framework ?? string.Empty, + ["style"] = step.Style?.ToString() ?? string.Empty, + ["bundle"] = step.BundleId ?? string.Empty, + ["configuration"] = plan.Configuration, + ["projectRoot"] = plan.ProjectRoot + }; + + var command = ApplyTemplate(step.HookCommand!, tokens); + var commandPath = ResolveHookCommandPath(plan.ProjectRoot, command); + var args = (step.HookArguments ?? Array.Empty()) + .Select(argument => ApplyTemplate(argument ?? string.Empty, tokens)) + .ToArray(); + var workingDirectory = string.IsNullOrWhiteSpace(step.HookWorkingDirectory) + ? plan.ProjectRoot + : ResolvePath(plan.ProjectRoot, ApplyTemplate(step.HookWorkingDirectory!, tokens)); + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in step.HookEnvironment ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(entry.Key)) continue; + environment[entry.Key.Trim()] = ApplyTemplate(entry.Value ?? string.Empty, tokens); + } + + _logger.Info($"Hook {step.HookPhase}: {step.HookId}"); + var result = RunHookProcess( + commandPath, + workingDirectory, + args, + environment, + TimeSpan.FromSeconds(Math.Max(1, step.HookTimeoutSeconds))); + + if (result.ExitCode == 0) + { + if (_logger.IsVerbose) + { + if (!string.IsNullOrWhiteSpace(result.StdOut)) _logger.Verbose(result.StdOut.TrimEnd()); + if (!string.IsNullOrWhiteSpace(result.StdErr)) _logger.Verbose(result.StdErr.TrimEnd()); + } + + return; + } + + var stderrTail = TailLines(result.StdErr, maxLines: 40, maxChars: 4000); + var stdoutTail = TailLines(result.StdOut, maxLines: 40, maxChars: 4000); + var message = ExtractLastNonEmptyLine(!string.IsNullOrWhiteSpace(stderrTail) ? stderrTail : stdoutTail); + if (string.IsNullOrWhiteSpace(message)) + message = $"Hook '{step.HookId}' failed with exit code {result.ExitCode}."; + + if (!step.HookRequired) + { + _logger.Warn(message); + return; + } + + throw new DotNetPublishCommandException( + message, + result.Executable, + string.IsNullOrWhiteSpace(workingDirectory) ? Environment.CurrentDirectory : workingDirectory, + args, + result.ExitCode, + result.StdOut, + result.StdErr); + } + + private static string ResolveHookCommandPath(string projectRoot, string command) + { + var raw = (command ?? string.Empty).Trim().Trim('"'); + if (string.IsNullOrWhiteSpace(raw)) + return raw; + if (Path.IsPathRooted(raw)) + return Path.GetFullPath(raw); + if (raw.Contains(Path.DirectorySeparatorChar) || raw.Contains(Path.AltDirectorySeparatorChar)) + return ResolvePath(projectRoot, raw); + return raw; + } + + private static (int ExitCode, string StdOut, string StdErr, string Executable) RunHookProcess( + string fileName, + string workingDirectory, + IReadOnlyList args, + IReadOnlyDictionary environment, + TimeSpan timeout) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Environment.CurrentDirectory : workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + ProcessStartInfoEncoding.TryApplyUtf8(psi); + + foreach (var entry in environment ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) + psi.Environment[entry.Key] = entry.Value ?? string.Empty; + +#if NET472 + psi.Arguments = BuildWindowsArgumentString(args); +#else + foreach (var arg in args ?? Array.Empty()) + psi.ArgumentList.Add(arg); +#endif + + using var process = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start hook command: {fileName}"); + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + var timeoutMs = (int)Math.Min(Math.Max(1, timeout.TotalMilliseconds), int.MaxValue); + + if (!process.WaitForExit(timeoutMs)) + { + try { process.Kill(); } catch { /* best effort */ } + var stdout = stdoutTask.GetAwaiter().GetResult(); + var stderr = stderrTask.GetAwaiter().GetResult(); + return (-1, stdout, stderr, fileName); + } + + return ( + process.ExitCode, + stdoutTask.GetAwaiter().GetResult(), + stderrTask.GetAwaiter().GetResult(), + fileName); + } +} diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs index 57fa87d1..d8f68ee2 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs @@ -175,7 +175,8 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) }); } - var bundles = BuildBundlePlans(spec.Bundles, targets, projectRoot); + var bundles = BuildBundlePlans(spec.Bundles, targets, projectRoot, spec.SigningProfiles); + targets = OrderTargetsForBundleIncludes(targets, bundles); var installers = BuildInstallerPlans(spec.Installers, bundles, targets, projectCatalog, projectRoot, spec.SigningProfiles); var storePackages = BuildStorePackagePlans(spec.StorePackages, targets, projectCatalog, projectRoot); @@ -196,6 +197,7 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) }; var benchmarkGates = BuildBenchmarkGatePlans(spec.BenchmarkGates, projectRoot); + var hooks = NormalizeCommandHooks(spec.Hooks); if (!spec.DotNet.AllowManifestOutsideProjectRoot) { @@ -295,11 +297,13 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) .OrderBy(r => r, StringComparer.OrdinalIgnoreCase) .ToArray(); - if (spec.DotNet.Clean) - steps.Add(new DotNetPublishStep { Key = "clean", Kind = DotNetPublishStepKind.Clean, Title = "Clean" }); - - if (spec.DotNet.Restore) - { + if (spec.DotNet.Clean) + steps.Add(new DotNetPublishStep { Key = "clean", Kind = DotNetPublishStepKind.Clean, Title = "Clean" }); + + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.BeforeRestore, cfg, targetName: null, framework: null, runtime: null, style: null, bundleId: null); + + if (spec.DotNet.Restore) + { if (spec.DotNet.NoRestoreInPublish && distinctRuntimes.Length > 0) { foreach (var rid in distinctRuntimes) @@ -316,11 +320,13 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) else { steps.Add(new DotNetPublishStep { Key = "restore", Kind = DotNetPublishStepKind.Restore, Title = "Restore" }); - } - } - - if (spec.DotNet.Build) - { + } + } + + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.BeforeBuild, cfg, targetName: null, framework: null, runtime: null, style: null, bundleId: null); + + if (spec.DotNet.Build) + { if (spec.DotNet.NoBuildInPublish && distinctRuntimes.Length > 0) { foreach (var rid in distinctRuntimes) @@ -344,6 +350,8 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) { foreach (var combo in t.Combinations ?? Array.Empty()) { + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.BeforeTargetPublish, cfg, t.Name, combo.Framework, combo.Runtime, combo.Style, bundleId: null); + var key = $"publish:{t.Name}:{combo.Framework}:{combo.Runtime}:{combo.Style}"; steps.Add(new DotNetPublishStep { @@ -356,6 +364,8 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) Style = combo.Style }); + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.AfterTargetPublish, cfg, t.Name, combo.Framework, combo.Runtime, combo.Style, bundleId: null); + if (t.Publish.Service?.Lifecycle?.Enabled == true && t.Publish.Service.Lifecycle.Mode == DotNetPublishServiceLifecycleMode.Step) { @@ -398,7 +408,9 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) "Use unique bundle IDs or path templates."); } + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.BeforeBundle, cfg, t.Name, combo.Framework, combo.Runtime, combo.Style, bundle.Id); steps.Add(bundleStep); + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.AfterBundle, cfg, t.Name, combo.Framework, combo.Runtime, combo.Style, bundle.Id); } foreach (var installer in installers.Where(i => string.Equals(i.PrepareFromTarget, t.Name, StringComparison.OrdinalIgnoreCase))) @@ -622,6 +634,7 @@ private static DotNetPublishSpec ResolveProfile(DotNetPublishSpec spec) || selectedTargetNames.Contains(i.PrepareFromTarget.Trim())) .ToArray(), BenchmarkGates = CloneBenchmarkGates(spec.BenchmarkGates), + Hooks = CloneCommandHooks(spec.Hooks), Matrix = CloneMatrix(spec.Matrix), DotNet = dotNet, Targets = selectedTargets, @@ -746,12 +759,17 @@ private static DotNetPublishBundle[] CloneBundles(DotNetPublishBundle[] bundles) Frameworks = NormalizeStrings(b.Frameworks), Styles = NormalizeStyles(b.Styles), OutputPath = b.OutputPath, + PrimarySubdirectory = b.PrimarySubdirectory, ClearOutput = b.ClearOutput, Zip = b.Zip, ZipPath = b.ZipPath, ZipNameTemplate = b.ZipNameTemplate, Includes = CloneBundleIncludes(b.Includes), - Scripts = CloneBundleScripts(b.Scripts) + CopyItems = CloneBundleCopyItems(b.CopyItems), + ModuleIncludes = CloneBundleModuleIncludes(b.ModuleIncludes), + GeneratedScripts = CloneBundleGeneratedScripts(b.GeneratedScripts), + Scripts = CloneBundleScripts(b.Scripts), + PostProcess = b.PostProcess }) .ToArray(); } @@ -772,6 +790,53 @@ private static DotNetPublishBundleInclude[] CloneBundleIncludes(DotNetPublishBun .ToArray(); } + private static DotNetPublishBundleCopyItem[] CloneBundleCopyItems(DotNetPublishBundleCopyItem[] items) + { + return (items ?? Array.Empty()) + .Where(i => i is not null) + .Select(i => new DotNetPublishBundleCopyItem + { + SourcePath = i.SourcePath, + DestinationPath = i.DestinationPath, + Required = i.Required, + ClearDestination = i.ClearDestination + }) + .ToArray(); + } + + private static DotNetPublishBundleModuleInclude[] CloneBundleModuleIncludes(DotNetPublishBundleModuleInclude[] modules) + { + return (modules ?? Array.Empty()) + .Where(m => m is not null) + .Select(m => new DotNetPublishBundleModuleInclude + { + ModuleName = m.ModuleName, + SourcePath = m.SourcePath, + DestinationPath = m.DestinationPath, + Required = m.Required, + ClearDestination = m.ClearDestination + }) + .ToArray(); + } + + private static DotNetPublishBundleGeneratedScript[] CloneBundleGeneratedScripts(DotNetPublishBundleGeneratedScript[] scripts) + { + return (scripts ?? Array.Empty()) + .Where(s => s is not null) + .Select(s => new DotNetPublishBundleGeneratedScript + { + TemplatePath = s.TemplatePath, + Template = s.Template, + OutputPath = s.OutputPath, + Tokens = CloneDictionary(s.Tokens), + Overwrite = s.Overwrite, + SignProfile = s.SignProfile, + Sign = DotNetPublishSigningProfileResolver.CloneSignOptions(s.Sign), + SignOverrides = DotNetPublishSigningProfileResolver.CloneSignPatch(s.SignOverrides) + }) + .ToArray(); + } + private static DotNetPublishBundleScript[] CloneBundleScripts(DotNetPublishBundleScript[] scripts) { return (scripts ?? Array.Empty()) @@ -788,6 +853,28 @@ private static DotNetPublishBundleScript[] CloneBundleScripts(DotNetPublishBundl .ToArray(); } + private static DotNetPublishCommandHook[] CloneCommandHooks(DotNetPublishCommandHook[]? hooks) + { + return (hooks ?? Array.Empty()) + .Where(h => h is not null) + .Select(h => new DotNetPublishCommandHook + { + Id = h.Id, + Phase = h.Phase, + Command = h.Command, + Arguments = NormalizeArguments(h.Arguments), + WorkingDirectory = h.WorkingDirectory, + Environment = CloneDictionary(h.Environment), + TimeoutSeconds = h.TimeoutSeconds, + Required = h.Required, + Targets = NormalizeStrings(h.Targets), + Runtimes = NormalizeStrings(h.Runtimes), + Frameworks = NormalizeStrings(h.Frameworks), + Styles = NormalizeStyles(h.Styles) + }) + .ToArray(); + } + private static DotNetPublishBenchmarkGate[] CloneBenchmarkGates(DotNetPublishBenchmarkGate[]? gates) { return (gates ?? Array.Empty()) @@ -1058,6 +1145,14 @@ private static string[] NormalizeStrings(IEnumerable? values) .ToArray(); } + private static string[] NormalizeArguments(IEnumerable? values) + { + return (values ?? Array.Empty()) + .Where(v => v is not null) + .Select(v => v ?? string.Empty) + .ToArray(); + } + private static DotNetPublishStyle[] NormalizeStyles(IEnumerable? values) { return (values ?? Array.Empty()) @@ -1065,6 +1160,119 @@ private static DotNetPublishStyle[] NormalizeStyles(IEnumerable? hooks) + { + var result = new List(); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var hook in hooks ?? Array.Empty()) + { + if (hook is null) continue; + var id = (hook.Id ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Hooks[].Id is required."); + if (!ids.Add(id)) + throw new ArgumentException($"Duplicate hook ID detected: {id}"); + if (string.IsNullOrWhiteSpace(hook.Command)) + throw new ArgumentException($"Hook '{id}' requires Command."); + + result.Add(new DotNetPublishCommandHook + { + Id = id, + Phase = hook.Phase, + Command = hook.Command.Trim(), + Arguments = NormalizeArguments(hook.Arguments), + WorkingDirectory = string.IsNullOrWhiteSpace(hook.WorkingDirectory) ? null : hook.WorkingDirectory!.Trim(), + Environment = CloneDictionary(hook.Environment), + TimeoutSeconds = Math.Max(1, hook.TimeoutSeconds), + Required = hook.Required, + Targets = NormalizeStrings(hook.Targets), + Runtimes = NormalizeStrings(hook.Runtimes), + Frameworks = NormalizeStrings(hook.Frameworks), + Styles = NormalizeStyles(hook.Styles) + }); + } + + return result.ToArray(); + } + + private static void AddCommandHookSteps( + List steps, + IReadOnlyList hooks, + DotNetPublishCommandHookPhase phase, + string configuration, + string? targetName, + string? framework, + string? runtime, + DotNetPublishStyle? style, + string? bundleId) + { + foreach (var hook in hooks.Where(hook => hook.Phase == phase)) + { + if (!CommandHookMatches(hook, targetName, framework, runtime, style)) + continue; + + var suffixParts = new[] { targetName, framework, runtime, style?.ToString(), bundleId } + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Select(part => part!.Trim()) + .ToArray(); + var suffix = suffixParts.Length == 0 ? string.Empty : ":" + string.Join(":", suffixParts); + + steps.Add(new DotNetPublishStep + { + Key = $"hook:{phase}:{hook.Id}{suffix}", + Kind = DotNetPublishStepKind.CommandHook, + Title = $"Hook {phase}: {hook.Id}", + HookId = hook.Id, + HookPhase = phase, + HookCommand = hook.Command, + HookArguments = hook.Arguments, + HookWorkingDirectory = hook.WorkingDirectory, + HookEnvironment = hook.Environment ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + HookTimeoutSeconds = Math.Max(1, hook.TimeoutSeconds), + HookRequired = hook.Required, + TargetName = targetName, + Framework = framework, + Runtime = runtime, + Style = style, + BundleId = bundleId + }); + } + } + + private static bool CommandHookMatches( + DotNetPublishCommandHook hook, + string? targetName, + string? framework, + string? runtime, + DotNetPublishStyle? style) + { + if (hook.Targets.Length > 0) + { + if (string.IsNullOrWhiteSpace(targetName) || !hook.Targets.Any(pattern => WildcardMatch(targetName!, pattern))) + return false; + } + + if (hook.Frameworks.Length > 0) + { + if (string.IsNullOrWhiteSpace(framework) || !hook.Frameworks.Any(pattern => WildcardMatch(framework!, pattern))) + return false; + } + + if (hook.Runtimes.Length > 0) + { + if (string.IsNullOrWhiteSpace(runtime) || !hook.Runtimes.Any(pattern => WildcardMatch(runtime!, pattern))) + return false; + } + + if (hook.Styles.Length > 0) + { + if (!style.HasValue || !hook.Styles.Contains(style.Value)) + return false; + } + + return true; + } + private static Dictionary BuildProjectCatalog(IEnumerable? projects, string projectRoot) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -1106,7 +1314,8 @@ private static string ResolveTargetProjectPath(string projectRoot, DotNetPublish private static DotNetPublishBundlePlan[] BuildBundlePlans( IEnumerable? bundles, IEnumerable? targets, - string projectRoot) + string projectRoot, + IReadOnlyDictionary? signingProfiles) { var targetPlans = (targets ?? Array.Empty()) .Where(t => t is not null && !string.IsNullOrWhiteSpace(t.Name)) @@ -1165,6 +1374,73 @@ private static DotNetPublishBundlePlan[] BuildBundlePlans( }); } + var copyItemPlans = new List(); + foreach (var item in bundle.CopyItems ?? Array.Empty()) + { + if (item is null) continue; + if (string.IsNullOrWhiteSpace(item.SourcePath)) + throw new ArgumentException($"Bundle '{id}' CopyItems[] requires SourcePath."); + if (string.IsNullOrWhiteSpace(item.DestinationPath)) + throw new ArgumentException($"Bundle '{id}' CopyItems[] requires DestinationPath."); + + copyItemPlans.Add(new DotNetPublishBundleCopyItemPlan + { + SourcePath = item.SourcePath.Trim(), + DestinationPath = item.DestinationPath.Trim(), + Required = item.Required, + ClearDestination = item.ClearDestination + }); + } + + var moduleIncludePlans = new List(); + foreach (var module in bundle.ModuleIncludes ?? Array.Empty()) + { + if (module is null) continue; + var moduleName = (module.ModuleName ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(moduleName)) + throw new ArgumentException($"Bundle '{id}' ModuleIncludes[] requires ModuleName."); + if (string.IsNullOrWhiteSpace(module.SourcePath)) + throw new ArgumentException($"Bundle '{id}' ModuleIncludes['{moduleName}'] requires SourcePath."); + + moduleIncludePlans.Add(new DotNetPublishBundleModuleIncludePlan + { + ModuleName = moduleName, + SourcePath = module.SourcePath.Trim(), + DestinationPath = string.IsNullOrWhiteSpace(module.DestinationPath) + ? $"Modules/{{moduleName}}" + : module.DestinationPath!.Trim(), + Required = module.Required, + ClearDestination = module.ClearDestination + }); + } + + var generatedScriptPlans = new List(); + foreach (var generated in bundle.GeneratedScripts ?? Array.Empty()) + { + if (generated is null) continue; + if (string.IsNullOrWhiteSpace(generated.OutputPath)) + throw new ArgumentException($"Bundle '{id}' GeneratedScripts[] requires OutputPath."); + if (string.IsNullOrWhiteSpace(generated.TemplatePath) && string.IsNullOrWhiteSpace(generated.Template)) + throw new ArgumentException($"Bundle '{id}' GeneratedScripts['{generated.OutputPath}'] requires TemplatePath or Template."); + + generatedScriptPlans.Add(new DotNetPublishBundleGeneratedScriptPlan + { + TemplatePath = string.IsNullOrWhiteSpace(generated.TemplatePath) + ? null + : generated.TemplatePath!.Trim(), + Template = generated.Template, + OutputPath = generated.OutputPath.Trim(), + Tokens = CloneDictionary(generated.Tokens) ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + Overwrite = generated.Overwrite, + Sign = DotNetPublishSigningProfileResolver.ResolveConfiguredSignOptions( + signingProfiles, + generated.SignProfile, + generated.Sign, + generated.SignOverrides, + $"Bundle '{id}' generated script '{generated.OutputPath}'") + }); + } + var scriptPlans = new List(); foreach (var script in bundle.Scripts ?? Array.Empty()) { @@ -1196,11 +1472,17 @@ private static DotNetPublishBundlePlan[] BuildBundlePlans( Frameworks = frameworks, Styles = styles, OutputPath = bundle.OutputPath, + PrimarySubdirectory = string.IsNullOrWhiteSpace(bundle.PrimarySubdirectory) + ? null + : bundle.PrimarySubdirectory!.Trim(), ClearOutput = bundle.ClearOutput, Zip = bundle.Zip, ZipPath = bundle.ZipPath, ZipNameTemplate = bundle.ZipNameTemplate, Includes = includePlans.ToArray(), + CopyItems = copyItemPlans.ToArray(), + ModuleIncludes = moduleIncludePlans.ToArray(), + GeneratedScripts = generatedScriptPlans.ToArray(), Scripts = scriptPlans.ToArray(), PostProcess = NormalizeBundlePostProcess(id, bundle.PostProcess) }); @@ -1310,6 +1592,71 @@ private static DotNetPublishInstallerPlan[] BuildInstallerPlans( return plans.ToArray(); } + private static List OrderTargetsForBundleIncludes( + List targets, + IReadOnlyList bundles) + { + if (targets is null || targets.Count < 2 || bundles is null || bundles.Count == 0) + return targets ?? new List(); + + var targetMap = targets + .Where(t => t is not null && !string.IsNullOrWhiteSpace(t.Name)) + .ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase); + var dependencies = targetMap.Keys.ToDictionary( + name => name, + _ => new HashSet(StringComparer.OrdinalIgnoreCase), + StringComparer.OrdinalIgnoreCase); + + foreach (var bundle in bundles) + { + if (bundle is null || string.IsNullOrWhiteSpace(bundle.PrepareFromTarget)) + continue; + if (!dependencies.TryGetValue(bundle.PrepareFromTarget, out var sourceDependencies)) + continue; + + foreach (var include in bundle.Includes ?? Array.Empty()) + { + if (include is null || string.IsNullOrWhiteSpace(include.Target)) + continue; + if (targetMap.ContainsKey(include.Target)) + sourceDependencies.Add(include.Target); + } + } + + if (dependencies.Values.All(set => set.Count == 0)) + return targets; + + var ordered = new List(targets.Count); + var visiting = new HashSet(StringComparer.OrdinalIgnoreCase); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Visit(string targetName) + { + if (visited.Contains(targetName)) + return; + if (!visiting.Add(targetName)) + throw new ArgumentException($"Bundle include target dependency cycle detected at '{targetName}'."); + + if (dependencies.TryGetValue(targetName, out var deps)) + { + foreach (var dependency in deps) + { + if (targetMap.ContainsKey(dependency)) + Visit(dependency); + } + } + + visiting.Remove(targetName); + visited.Add(targetName); + ordered.Add(targetMap[targetName]); + } + + foreach (var target in targets) + Visit(target.Name); + + return ordered; + } + private static DotNetPublishStorePackagePlan[] BuildStorePackagePlans( IEnumerable? storePackages, IEnumerable? targets, diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Run.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Run.cs index a9a18407..487db203 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Run.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Run.cs @@ -42,9 +42,12 @@ public DotNetPublishResult Run(DotNetPublishPlan plan, IDotNetPublishProgressRep { switch (step.Kind) { + case DotNetPublishStepKind.CommandHook: + RunCommandHook(plan, step); + break; case DotNetPublishStepKind.Restore: - Restore(plan, step.Runtime); - break; + Restore(plan, step.Runtime); + break; case DotNetPublishStepKind.Clean: Clean(plan); break; diff --git a/Schemas/powerforge.dotnetpublish.schema.json b/Schemas/powerforge.dotnetpublish.schema.json index 6fce7a15..6a147c6d 100644 --- a/Schemas/powerforge.dotnetpublish.schema.json +++ b/Schemas/powerforge.dotnetpublish.schema.json @@ -42,6 +42,10 @@ "type": "array", "items": { "$ref": "#/$defs/DotNetPublishBenchmarkGate" } }, + "Hooks": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishCommandHook" } + }, "Outputs": { "$ref": "#/$defs/DotNetPublishOutputs" } }, "required": ["Targets"], @@ -86,6 +90,10 @@ "type": "string", "enum": ["First", "Last", "Min", "Max", "Average"] }, + "DotNetPublishCommandHookPhase": { + "type": "string", + "enum": ["BeforeRestore", "BeforeBuild", "BeforeTargetPublish", "AfterTargetPublish", "BeforeBundle", "AfterBundle"] + }, "DotNetPublishBaselineMode": { "type": "string", "enum": ["Verify", "Update"] @@ -410,6 +418,51 @@ }, "required": ["Target"] }, + "DotNetPublishBundleCopyItem": { + "type": "object", + "additionalProperties": false, + "properties": { + "SourcePath": { "type": "string", "minLength": 1 }, + "DestinationPath": { "type": "string", "minLength": 1 }, + "Required": { "type": "boolean" }, + "ClearDestination": { "type": "boolean" } + }, + "required": ["SourcePath", "DestinationPath"] + }, + "DotNetPublishBundleModuleInclude": { + "type": "object", + "additionalProperties": false, + "properties": { + "ModuleName": { "type": "string", "minLength": 1 }, + "SourcePath": { "type": "string", "minLength": 1 }, + "DestinationPath": { "type": ["string", "null"] }, + "Required": { "type": "boolean" }, + "ClearDestination": { "type": "boolean" } + }, + "required": ["ModuleName", "SourcePath"] + }, + "DotNetPublishBundleGeneratedScript": { + "type": "object", + "additionalProperties": false, + "properties": { + "TemplatePath": { "type": ["string", "null"] }, + "Template": { "type": ["string", "null"] }, + "OutputPath": { "type": "string", "minLength": 1 }, + "Tokens": { + "type": ["object", "null"], + "additionalProperties": { "type": "string" } + }, + "Overwrite": { "type": "boolean" }, + "SignProfile": { "type": ["string", "null"] }, + "Sign": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignOptions" }, { "type": "null" }] }, + "SignOverrides": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignPatch" }, { "type": "null" }] } + }, + "required": ["OutputPath"], + "anyOf": [ + { "required": ["TemplatePath"] }, + { "required": ["Template"] } + ] + }, "DotNetPublishBundleScript": { "type": "object", "additionalProperties": false, @@ -472,6 +525,7 @@ "Frameworks": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "Styles": { "type": "array", "items": { "$ref": "#/$defs/DotNetPublishStyle" } }, "OutputPath": { "type": ["string", "null"] }, + "PrimarySubdirectory": { "type": ["string", "null"] }, "ClearOutput": { "type": "boolean" }, "Zip": { "type": "boolean" }, "ZipPath": { "type": ["string", "null"] }, @@ -480,6 +534,18 @@ "type": "array", "items": { "$ref": "#/$defs/DotNetPublishBundleInclude" } }, + "CopyItems": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishBundleCopyItem" } + }, + "ModuleIncludes": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishBundleModuleInclude" } + }, + "GeneratedScripts": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishBundleGeneratedScript" } + }, "Scripts": { "type": "array", "items": { "$ref": "#/$defs/DotNetPublishBundleScript" } @@ -488,6 +554,43 @@ }, "required": ["Id", "PrepareFromTarget"] }, + "DotNetPublishCommandHook": { + "type": "object", + "additionalProperties": false, + "properties": { + "Id": { "type": "string", "minLength": 1 }, + "Phase": { "$ref": "#/$defs/DotNetPublishCommandHookPhase" }, + "Command": { "type": "string", "minLength": 1 }, + "Arguments": { + "type": "array", + "items": { "type": "string" } + }, + "WorkingDirectory": { "type": ["string", "null"] }, + "Environment": { + "type": ["object", "null"], + "additionalProperties": { "type": "string" } + }, + "TimeoutSeconds": { "type": "integer", "minimum": 1 }, + "Required": { "type": "boolean" }, + "Targets": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "Runtimes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "Frameworks": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "Styles": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishStyle" } + } + }, + "required": ["Id", "Phase", "Command"] + }, "DotNetPublishProfile": { "type": "object", "additionalProperties": false, diff --git a/Schemas/powerforge.plugins.schema.json b/Schemas/powerforge.plugins.schema.json new file mode 100644 index 00000000..b7a91044 --- /dev/null +++ b/Schemas/powerforge.plugins.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "powerforge.plugins.schema.json", + "title": "PowerForge Plugin CatalogSpec (schema v1)", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "ProjectRoot": { "type": ["string", "null"] }, + "Configuration": { "type": "string", "default": "Release" }, + "Catalog": { + "type": "array", + "items": { "$ref": "#/$defs/PowerForgePluginCatalogEntry" } + } + }, + "required": ["Catalog"], + "$defs": { + "PowerForgePluginCatalogEntry": { + "type": "object", + "additionalProperties": false, + "properties": { + "Id": { "type": "string", "minLength": 1 }, + "ProjectPath": { "type": "string", "minLength": 1 }, + "Groups": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "Framework": { "type": ["string", "null"] }, + "PackageId": { "type": ["string", "null"] }, + "AssemblyName": { "type": ["string", "null"] }, + "MsBuildProperties": { + "type": ["object", "null"], + "additionalProperties": { "type": "string" } + }, + "Manifest": { + "anyOf": [ + { "$ref": "#/$defs/PowerForgePluginManifestOptions" }, + { "type": "null" } + ] + } + }, + "required": ["Id", "ProjectPath"] + }, + "PowerForgePluginManifestOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { "type": "boolean", "default": true }, + "IncludeStandardProperties": { "type": "boolean", "default": true }, + "FileName": { "type": "string", "default": "plugin.manifest.json" }, + "EntryAssembly": { "type": ["string", "null"] }, + "EntryType": { "type": ["string", "null"] }, + "EntryTypeMatchBaseType": { "type": ["string", "null"] }, + "Properties": { + "type": ["object", "null"], + "additionalProperties": { "type": "string" } + } + } + } + } +} From c01aa16f00c4bb02ed00cf9d1df0dc6dd53b571c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 16:33:43 +0200 Subject: [PATCH 02/16] Add MSI harvest exclude patterns --- ...NetPublishPipelineRunnerMsiPrepareTests.cs | 77 +++++++++++++++++++ .../Models/DotNetPublish/DotNetPublishPlan.cs | 3 + .../Models/DotNetPublish/DotNetPublishSpec.cs | 6 ++ .../DotNetPublishPipelineRunner.MsiPrepare.cs | 50 +++++++++++- .../DotNetPublishPipelineRunner.Plan.cs | 2 + Schemas/powerforge.dotnetpublish.schema.json | 1 + 6 files changed, 137 insertions(+), 2 deletions(-) diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs index 82fbce7c..172c4253 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs @@ -283,6 +283,83 @@ public void PrepareMsiPackage_WritesHarvestFragment_WhenConfigured() } } + [Fact] + public void PrepareMsiPackage_ExcludesConfiguredHarvestPatterns() + { + var root = CreateTempRoot(); + try + { + var outputDir = Directory.CreateDirectory(Path.Combine(root, "publish", "svc")).FullName; + File.WriteAllText(Path.Combine(outputDir, "Svc.exe"), "dummy"); + File.WriteAllText(Path.Combine(outputDir, "Install-Service.ps1"), "install"); + Directory.CreateDirectory(Path.Combine(outputDir, "data")); + File.WriteAllText(Path.Combine(outputDir, "data", "settings.json"), "{ }"); + + var stagingPath = Path.Combine(root, "Artifacts", "Msi", "svc.msi", "payload"); + var manifestPath = Path.Combine(root, "Artifacts", "Msi", "svc.msi", "prepare.manifest.json"); + var harvestPath = Path.Combine(root, "Artifacts", "Msi", "svc.msi", "harvest.wxs"); + + var step = new DotNetPublishStep + { + Key = "msi.prepare:svc.msi:svc:net10.0:win-x64:Portable", + Kind = DotNetPublishStepKind.MsiPrepare, + Title = "MSI prepare", + InstallerId = "svc.msi", + TargetName = "svc", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.Portable, + StagingPath = stagingPath, + ManifestPath = manifestPath, + HarvestPath = harvestPath, + HarvestDirectoryRefId = "INSTALLFOLDER", + HarvestComponentGroupId = "Harvest_svc_msi" + }; + + var plan = new DotNetPublishPlan + { + ProjectRoot = root, + AllowOutputOutsideProjectRoot = false, + Installers = new[] + { + new DotNetPublishInstallerPlan + { + Id = "svc.msi", + HarvestExcludePatterns = new[] { "Svc.exe", "**/Install-Service.ps1" } + } + } + }; + + var artefacts = new[] + { + new DotNetPublishArtefactResult + { + Target = "svc", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.Portable, + OutputDir = outputDir + } + }; + + var runner = new DotNetPublishPipelineRunner(new NullLogger()); + var method = typeof(DotNetPublishPipelineRunner).GetMethod("PrepareMsiPackage", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var result = method!.Invoke(runner, new object[] { plan, artefacts, step }) as DotNetPublishMsiPrepareResult; + Assert.NotNull(result); + + var wxs = File.ReadAllText(result!.HarvestPath!); + Assert.DoesNotContain("Svc.exe", wxs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Install-Service.ps1", wxs, StringComparison.OrdinalIgnoreCase); + Assert.Contains("settings.json", wxs, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + private static DotNetPublishSpec CreateBaseSpec(string root, string projectPath) { return new DotNetPublishSpec diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs b/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs index 35c61c67..e7fd8c2e 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs @@ -152,6 +152,9 @@ public sealed class DotNetPublishInstallerPlan /// Optional WiX component group ID template. public string? HarvestComponentGroupId { get; set; } + /// Optional wildcard patterns excluded from generated WiX harvest output. + public string[] HarvestExcludePatterns { get; set; } = Array.Empty(); + /// Optional MSI version policy used by build steps. public DotNetPublishMsiVersionOptions? Versioning { get; set; } diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs index ceb5cd6f..c3ad1b07 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs @@ -199,6 +199,12 @@ public sealed class DotNetPublishInstaller /// public string? HarvestComponentGroupId { get; set; } + /// + /// Optional wildcard patterns excluded from generated WiX harvest output. + /// Patterns match paths relative to the MSI staging root using forward slashes. + /// + public string[] HarvestExcludePatterns { get; set; } = Array.Empty(); + /// /// Optional MSI versioning policy used by msi.build. /// diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.MsiPrepare.cs b/PowerForge/Services/DotNetPublishPipelineRunner.MsiPrepare.cs index 46e1fa25..20a8e285 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.MsiPrepare.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.MsiPrepare.cs @@ -34,6 +34,7 @@ internal DotNetPublishMsiPrepareResult PrepareMsiPackage( if (string.IsNullOrWhiteSpace(step.ManifestPath)) throw new InvalidOperationException($"Step '{step.Key}' is missing manifest path."); + var installerPlan = ResolveInstallerPlan(plan, installerId); var sourceBundleId = ResolveInstallerSourceBundleId(plan, installerId, step.BundleId); var sourceArtefact = ResolveInstallerSourceArtefact( artefacts, @@ -103,7 +104,11 @@ internal DotNetPublishMsiPrepareResult PrepareMsiPackage( Directory.CreateDirectory(Path.GetDirectoryName(resolvedHarvestPath)!); File.WriteAllText( resolvedHarvestPath, - BuildWixHarvestFragment(stagingPath, resolvedHarvestDirectoryRefId, resolvedHarvestComponentGroupId), + BuildWixHarvestFragment( + stagingPath, + resolvedHarvestDirectoryRefId, + resolvedHarvestComponentGroupId, + installerPlan?.HarvestExcludePatterns), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); _logger.Info($"MSI prepare harvest -> {resolvedHarvestPath}"); @@ -140,6 +145,12 @@ internal DotNetPublishMsiPrepareResult PrepareMsiPackage( return result; } + private static DotNetPublishInstallerPlan? ResolveInstallerPlan(DotNetPublishPlan plan, string installerId) + { + return (plan.Installers ?? Array.Empty()) + .FirstOrDefault(entry => string.Equals(entry.Id, installerId, StringComparison.OrdinalIgnoreCase)); + } + private static string? ResolveInstallerSourceBundleId( DotNetPublishPlan plan, string installerId, @@ -178,9 +189,12 @@ internal DotNetPublishMsiPrepareResult PrepareMsiPackage( internal static string BuildWixHarvestFragment( string stagingPath, string directoryRefId, - string componentGroupId) + string componentGroupId, + IEnumerable? excludePatterns = null) { + var normalizedExcludes = NormalizeHarvestExcludePatterns(excludePatterns); var files = Directory.EnumerateFiles(stagingPath, "*", SearchOption.AllDirectories) + .Where(file => !ShouldExcludeHarvestFile(stagingPath, file, normalizedExcludes)) .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -268,6 +282,38 @@ static void AppendDirectories( return sb.ToString(); } + private static string[] NormalizeHarvestExcludePatterns(IEnumerable? patterns) + { + return (patterns ?? Array.Empty()) + .Where(pattern => !string.IsNullOrWhiteSpace(pattern)) + .Select(pattern => pattern.Trim().Replace('\\', '/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool ShouldExcludeHarvestFile(string stagingPath, string file, IReadOnlyList excludePatterns) + { + if (excludePatterns is null || excludePatterns.Count == 0) + return false; + + var relative = GetRelativePathCompat(stagingPath, file).Replace('\\', '/'); + var fileName = Path.GetFileName(relative); + foreach (var pattern in excludePatterns) + { + var rootlessGlobstar = pattern.StartsWith("**/", StringComparison.Ordinal) + ? pattern.Substring(3) + : null; + if (WildcardMatch(relative, pattern) + || WildcardMatch(fileName, pattern) + || (rootlessGlobstar is not null && WildcardMatch(relative, rootlessGlobstar))) + { + return true; + } + } + + return false; + } + private static string BuildHarvestId(string prefix, string value) { using var sha256 = SHA256.Create(); diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs index d8f68ee2..58143c37 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs @@ -712,6 +712,7 @@ private static DotNetPublishInstaller[] CloneInstallers(DotNetPublishInstaller[] HarvestPath = i.HarvestPath, HarvestDirectoryRefId = i.HarvestDirectoryRefId, HarvestComponentGroupId = i.HarvestComponentGroupId, + HarvestExcludePatterns = NormalizeStrings(i.HarvestExcludePatterns), Versioning = CloneMsiVersionOptions(i.Versioning), MsBuildProperties = CloneDictionary(i.MsBuildProperties), SignProfile = i.SignProfile, @@ -1577,6 +1578,7 @@ private static DotNetPublishInstallerPlan[] BuildInstallerPlans( HarvestPath = installer.HarvestPath, HarvestDirectoryRefId = installer.HarvestDirectoryRefId, HarvestComponentGroupId = installer.HarvestComponentGroupId, + HarvestExcludePatterns = NormalizeStrings(installer.HarvestExcludePatterns), Versioning = NormalizeInstallerVersioning(id, installer.Versioning), MsBuildProperties = CloneDictionary(installer.MsBuildProperties), Sign = DotNetPublishSigningProfileResolver.ResolveConfiguredSignOptions( diff --git a/Schemas/powerforge.dotnetpublish.schema.json b/Schemas/powerforge.dotnetpublish.schema.json index 6a147c6d..14c50097 100644 --- a/Schemas/powerforge.dotnetpublish.schema.json +++ b/Schemas/powerforge.dotnetpublish.schema.json @@ -366,6 +366,7 @@ "HarvestPath": { "type": ["string", "null"] }, "HarvestDirectoryRefId": { "type": ["string", "null"] }, "HarvestComponentGroupId": { "type": ["string", "null"] }, + "HarvestExcludePatterns": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "Versioning": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishMsiVersionOptions" }, { "type": "null" }] }, "MsBuildProperties": { "type": ["object", "null"], From bed6ce9f1456278283739f548db9ee00cfb2f23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 20:24:20 +0200 Subject: [PATCH 03/16] Add dotnet publish project root override --- .../Cmdlets/InvokeDotNetPublishCommand.cs | 8 ++++ .../Models/DotNetPublishPreparationRequest.cs | 1 + .../DotNetPublishPreparationService.cs | 5 +++ .../DotNetPublishPreparationServiceTests.cs | 38 +++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/PSPublishModule/Cmdlets/InvokeDotNetPublishCommand.cs b/PSPublishModule/Cmdlets/InvokeDotNetPublishCommand.cs index 371a6cdf..c45dc5d2 100644 --- a/PSPublishModule/Cmdlets/InvokeDotNetPublishCommand.cs +++ b/PSPublishModule/Cmdlets/InvokeDotNetPublishCommand.cs @@ -48,6 +48,13 @@ public sealed class InvokeDotNetPublishCommand : PSCmdlet [ValidateNotNullOrEmpty] public string ConfigPath { get; set; } = string.Empty; + /// + /// Optional project root override used to resolve relative publish inputs and outputs. + /// + [Parameter(ParameterSetName = ParameterSetSettings)] + [Parameter(ParameterSetName = ParameterSetConfig)] + public string? ProjectRoot { get; set; } + /// /// Optional profile override. /// @@ -163,6 +170,7 @@ protected override void ProcessRecord() ResolvePath = path => SessionState.Path.GetUnresolvedProviderPathFromPSPath(path), Settings = Settings, ConfigPath = ConfigPath, + ProjectRoot = ProjectRoot, Profile = Profile, Target = Target, Runtimes = Runtimes, diff --git a/PowerForge.PowerShell/Models/DotNetPublishPreparationRequest.cs b/PowerForge.PowerShell/Models/DotNetPublishPreparationRequest.cs index c30c08a7..ba7dedd5 100644 --- a/PowerForge.PowerShell/Models/DotNetPublishPreparationRequest.cs +++ b/PowerForge.PowerShell/Models/DotNetPublishPreparationRequest.cs @@ -8,6 +8,7 @@ internal sealed class DotNetPublishPreparationRequest public string CurrentPath { get; set; } = string.Empty; public ScriptBlock? Settings { get; set; } public string? ConfigPath { get; set; } + public string? ProjectRoot { get; set; } public string? Profile { get; set; } public string[]? Target { get; set; } public string[]? Runtimes { get; set; } diff --git a/PowerForge.PowerShell/Services/DotNetPublishPreparationService.cs b/PowerForge.PowerShell/Services/DotNetPublishPreparationService.cs index b946a46f..632afed2 100644 --- a/PowerForge.PowerShell/Services/DotNetPublishPreparationService.cs +++ b/PowerForge.PowerShell/Services/DotNetPublishPreparationService.cs @@ -25,6 +25,11 @@ public DotNetPublishPreparedContext Prepare(DotNetPublishPreparationRequest requ var spec = LoadSpec(request, ref sourceLabel, warn); if (!string.IsNullOrWhiteSpace(request.Profile)) spec.Profile = request.Profile!.Trim(); + if (!string.IsNullOrWhiteSpace(request.ProjectRoot)) + { + spec.DotNet ??= new DotNetPublishDotNetOptions(); + spec.DotNet.ProjectRoot = request.ResolvePath!(request.ProjectRoot!); + } ApplyOverrides(spec, request); return new DotNetPublishPreparedContext diff --git a/PowerForge.Tests/DotNetPublishPreparationServiceTests.cs b/PowerForge.Tests/DotNetPublishPreparationServiceTests.cs index d421855c..5be4e081 100644 --- a/PowerForge.Tests/DotNetPublishPreparationServiceTests.cs +++ b/PowerForge.Tests/DotNetPublishPreparationServiceTests.cs @@ -113,4 +113,42 @@ public void Prepare_from_settings_defaults_json_path_to_current_path() try { root.Delete(recursive: true); } catch { } } } + + [Fact] + public void Prepare_from_config_applies_project_root_override() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pf-dotnet-publish-root-" + Guid.NewGuid().ToString("N"))); + + try + { + var repoRoot = Directory.CreateDirectory(Path.Combine(root.FullName, "repo")); + var configPath = Path.Combine(root.FullName, "Build", "publish.json"); + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + File.WriteAllText(configPath, """ +{ + "dotNet": { + "projectRoot": ".." + }, + "targets": [] +} +"""); + + var request = new DotNetPublishPreparationRequest + { + ParameterSetName = "Config", + CurrentPath = root.FullName, + ResolvePath = path => Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(root.FullName, path)), + ConfigPath = configPath, + ProjectRoot = repoRoot.FullName + }; + + var context = new DotNetPublishPreparationService(new NullLogger()).Prepare(request); + + Assert.Equal(repoRoot.FullName, context.Spec.DotNet.ProjectRoot); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } } From f63c9738d16b99292c249fadffa4876a34eec959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 20:39:25 +0200 Subject: [PATCH 04/16] Allow module builds from JSON config --- .../Cmdlets/InvokeModuleBuildCommand.cs | 29 +++++- .../Models/ModuleBuildPreparationRequest.cs | 1 + .../Models/ModuleBuildPreparedContext.cs | 1 + .../Services/ModuleBuildPreparationService.cs | 94 ++++++++++++++++++- .../ModuleBuildPreparationServiceTests.cs | 50 ++++++++++ Schemas/powerforge.pipelinespec.schema.json | 20 ++++ 6 files changed, 189 insertions(+), 6 deletions(-) diff --git a/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs b/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs index d3994d12..38e12b3e 100644 --- a/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs +++ b/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs @@ -87,6 +87,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet { private const string ParameterSetModern = "Modern"; private const string ParameterSetConfiguration = "Configuration"; + private const string ParameterSetConfig = "Config"; /// /// Provides settings for the module in the form of a script block (DSL). @@ -94,6 +95,13 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet [Parameter(Position = 0, ParameterSetName = ParameterSetModern)] public ScriptBlock? Settings { get; set; } + /// + /// Path to a module pipeline JSON config generated by Invoke-ModuleBuild -JsonOnly. + /// + [Parameter(Mandatory = true, ParameterSetName = ParameterSetConfig)] + [ValidateNotNullOrEmpty] + public string ConfigPath { get; set; } = string.Empty; + /// /// Path to the parent folder where the project exists or should be created. The module project resolves to Path\ModuleName. When omitted, uses the parent of the calling script directory. /// @@ -137,6 +145,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string[] ExcludeDirectories { get; set; } = { ".git", @@ -161,6 +170,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string[] ExcludeFiles { get; set; } = { ".gitignore" }; /// @@ -216,6 +226,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter Legacy { get; set; } /// @@ -223,6 +234,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter NoInteractive { get; set; } /// Staging directory for the PowerForge pipeline. When omitted, a temporary folder is generated. @@ -276,6 +288,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter JsonOnly { get; set; } /// @@ -284,6 +297,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string? JsonPath { get; set; } /// @@ -291,6 +305,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string? DiagnosticsBaselinePath { get; set; } /// @@ -298,6 +313,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter GenerateDiagnosticsBaseline { get; set; } /// @@ -305,6 +321,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter UpdateDiagnosticsBaseline { get; set; } /// @@ -312,6 +329,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter FailOnNewDiagnostics { get; set; } /// @@ -319,6 +337,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] [ValidateSet(nameof(BuildDiagnosticSeverity.Warning), nameof(BuildDiagnosticSeverity.Error))] public BuildDiagnosticSeverity? FailOnDiagnosticsSeverity { get; set; } @@ -328,6 +347,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string[]? DiagnosticsBinaryConflictSearchRoot { get; set; } /// @@ -335,6 +355,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter ExitCode { get; set; } /// @@ -364,6 +385,7 @@ protected override void ProcessRecord() ParameterSetName = ParameterSetName, Settings = Settings, Configuration = Configuration, + ConfigPath = ConfigPath, ModuleName = ModuleName, InputPath = Path, StagingPath = StagingPath, @@ -406,10 +428,7 @@ protected override void ProcessRecord() return; } - var useLegacy = - Legacy.IsPresent || - ParameterSetName == ParameterSetConfiguration || - Settings is not null; + var useLegacy = preparation.UseLegacy; #pragma warning disable CA1031 // Legacy cmdlet UX: capture and report errors consistently BufferedLogger? interactiveBuffer = null; @@ -440,7 +459,7 @@ protected override void ProcessRecord() plan: plan, configLabel: configLabel), writeSummary: SpectrePipelineConsoleUi.WriteSummary) - .Execute(preparation, interactive, useLegacy ? "dsl" : "cmdlet"); + .Execute(preparation, interactive, preparation.ConfigLabel); } #pragma warning restore CA1031 diff --git a/PowerForge.PowerShell/Models/ModuleBuildPreparationRequest.cs b/PowerForge.PowerShell/Models/ModuleBuildPreparationRequest.cs index d3284e58..9dc84406 100644 --- a/PowerForge.PowerShell/Models/ModuleBuildPreparationRequest.cs +++ b/PowerForge.PowerShell/Models/ModuleBuildPreparationRequest.cs @@ -8,6 +8,7 @@ internal sealed class ModuleBuildPreparationRequest public string ParameterSetName { get; set; } = string.Empty; public ScriptBlock? Settings { get; set; } public IDictionary Configuration { get; set; } = new Hashtable(); + public string? ConfigPath { get; set; } public string? ModuleName { get; set; } public string? InputPath { get; set; } public string? StagingPath { get; set; } diff --git a/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs b/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs index 5b95cba8..725064d6 100644 --- a/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs +++ b/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs @@ -8,4 +8,5 @@ internal sealed class ModuleBuildPreparedContext public bool UseLegacy { get; set; } public ModulePipelineSpec PipelineSpec { get; set; } = new(); public string? JsonOutputPath { get; set; } + public string ConfigLabel { get; set; } = "cmdlet"; } diff --git a/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs b/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs index 164da372..52f677ee 100644 --- a/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs +++ b/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs @@ -18,6 +18,9 @@ public ModuleBuildPreparedContext Prepare(ModuleBuildPreparationRequest request) if (request.ResolvePath is null) throw new ArgumentException("ResolvePath is required.", nameof(request)); + if (string.Equals(request.ParameterSetName, "Config", StringComparison.Ordinal)) + return PrepareFromConfig(request); + var moduleName = string.Equals(request.ParameterSetName, "Configuration", StringComparison.Ordinal) ? LegacySegmentAdapter.ResolveModuleNameFromLegacyConfiguration(request.Configuration) : request.ModuleName; @@ -84,7 +87,42 @@ public ModuleBuildPreparedContext Prepare(ModuleBuildPreparationRequest request) BasePathForScaffold = basePathForScaffold, UseLegacy = useLegacy, PipelineSpec = spec, - JsonOutputPath = request.JsonOnly ? ResolveJsonOutputPath(request, projectRoot) : null + JsonOutputPath = request.JsonOnly ? ResolveJsonOutputPath(request, projectRoot) : null, + ConfigLabel = useLegacy ? "dsl" : "cmdlet" + }; + } + + private ModuleBuildPreparedContext PrepareFromConfig(ModuleBuildPreparationRequest request) + { + if (string.IsNullOrWhiteSpace(request.ConfigPath)) + throw new PSArgumentException("ConfigPath is required."); + + var configFullPath = request.ResolvePath!(request.ConfigPath!); + if (!File.Exists(configFullPath)) + throw new FileNotFoundException($"Module build config file not found: {configFullPath}", configFullPath); + + var spec = ReadPipelineSpecJson(configFullPath); + ResolvePipelineSpecPaths(spec, configFullPath); + + if (spec.Build is null) + throw new InvalidOperationException("Module build config requires a Build section."); + if (string.IsNullOrWhiteSpace(spec.Build.Name)) + throw new InvalidOperationException("Module build config requires Build.Name."); + if (string.IsNullOrWhiteSpace(spec.Build.SourcePath)) + throw new InvalidOperationException("Module build config requires Build.SourcePath."); + + if (string.IsNullOrWhiteSpace(spec.Build.Version)) + spec.Build.Version = ResolveBaseVersion(spec.Build.SourcePath, spec.Build.Name); + + return new ModuleBuildPreparedContext + { + ModuleName = spec.Build.Name, + ProjectRoot = spec.Build.SourcePath, + BasePathForScaffold = null, + UseLegacy = false, + PipelineSpec = spec, + JsonOutputPath = request.JsonOnly ? ResolveJsonOutputPath(request, spec.Build.SourcePath) : null, + ConfigLabel = configFullPath }; } @@ -111,6 +149,36 @@ public void WritePipelineSpecJson(ModulePipelineSpec spec, string jsonFullPath) File.WriteAllText(jsonFullPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } + public ModulePipelineSpec ReadPipelineSpecJson(string jsonFullPath) + { + if (string.IsNullOrWhiteSpace(jsonFullPath)) throw new ArgumentException("Json path is required.", nameof(jsonFullPath)); + + try + { + var json = File.ReadAllText(jsonFullPath); + var spec = JsonSerializer.Deserialize(json, CreateJsonOptions()); + return spec ?? throw new InvalidOperationException("Parsed config is null."); + } + catch (Exception ex) when (ex is not FileNotFoundException) + { + throw new InvalidOperationException($"Failed to parse module build config '{jsonFullPath}'. {ex.Message}", ex); + } + } + + public void ResolvePipelineSpecPaths(ModulePipelineSpec spec, string configFullPath) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + if (spec.Build is null) return; + + var baseDir = Path.GetDirectoryName(configFullPath) ?? Directory.GetCurrentDirectory(); + + spec.Build.SourcePath = ResolveConfigPath(baseDir, spec.Build.SourcePath); + spec.Build.StagingPath = ResolveConfigPathNullable(baseDir, spec.Build.StagingPath); + spec.Build.CsprojPath = ResolveConfigPathNullable(baseDir, spec.Build.CsprojPath); + if (spec.Diagnostics is not null && !string.IsNullOrWhiteSpace(spec.Diagnostics.BaselinePath)) + spec.Diagnostics.BaselinePath = ResolveConfigPath(baseDir, spec.Diagnostics.BaselinePath!); + } + private static string ResolveBaseVersion(string projectRoot, string moduleName, IReadOnlyList? segments) { var configuredVersion = ResolveConfiguredVersion(segments); @@ -192,6 +260,30 @@ private static string ResolveJsonOutputPath(ModuleBuildPreparationRequest reques return Path.Combine(projectRoot, "powerforge.json"); } + private static JsonSerializerOptions CreateJsonOptions() + { + var opts = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + opts.Converters.Add(new JsonStringEnumConverter()); + opts.Converters.Add(new ConfigurationSegmentJsonConverter()); + return opts; + } + + private static string ResolveConfigPath(string baseDir, string? path) + { + if (string.IsNullOrWhiteSpace(path)) return string.Empty; + return Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(baseDir, path)); + } + + private static string? ResolveConfigPathNullable(string baseDir, string? path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + return ResolveConfigPath(baseDir, path); + } + private static void PrepareSpecForJsonExport(ModulePipelineSpec spec, string jsonFullPath) { if (spec.Build is null) throw new ArgumentException("Spec.Build is required.", nameof(spec)); diff --git a/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs b/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs index e9fb7565..ebbd25d2 100644 --- a/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs +++ b/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs @@ -317,6 +317,56 @@ public void WritePipelineSpecJson_round_trips_pipeline_plan_without_losing_publi } } + [Fact] + public void Prepare_from_config_loads_pipeline_json_and_resolves_paths() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pf-modulebuild-config-json-" + Guid.NewGuid().ToString("N"))); + + try + { + var moduleRoot = Directory.CreateDirectory(Path.Combine(root.FullName, "Module")); + File.WriteAllText(Path.Combine(moduleRoot.FullName, "PowerTierBridge.psd1"), "@{ ModuleVersion = '4.0.2' }"); + + var configDir = Directory.CreateDirectory(Path.Combine(root.FullName, "Build")); + var configPath = Path.Combine(configDir.FullName, "module.build.json"); + File.WriteAllText(configPath, """ +{ + "Build": { + "Name": "PowerTierBridge", + "SourcePath": "../Module", + "StagingPath": "../Build/Artifacts/Module/Staging", + "CsprojPath": "../TierBridge.PowerShell/TierBridge.PowerShell.csproj", + "Version": "4.0.X" + }, + "Diagnostics": { + "BaselinePath": ".powerforge/module-baseline.json" + } +} +"""); + + var prepared = new ModuleBuildPreparationService().Prepare(new ModuleBuildPreparationRequest + { + ParameterSetName = "Config", + ConfigPath = configPath, + CurrentPath = root.FullName, + ResolvePath = path => Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(root.FullName, path)) + }); + + Assert.Equal("PowerTierBridge", prepared.ModuleName); + Assert.Equal(moduleRoot.FullName, prepared.ProjectRoot); + Assert.False(prepared.UseLegacy); + Assert.Null(prepared.BasePathForScaffold); + Assert.Equal(configPath, prepared.ConfigLabel); + Assert.Equal(Path.Combine(root.FullName, "Build", "Artifacts", "Module", "Staging"), prepared.PipelineSpec.Build.StagingPath); + Assert.Equal(Path.Combine(root.FullName, "TierBridge.PowerShell", "TierBridge.PowerShell.csproj"), prepared.PipelineSpec.Build.CsprojPath); + Assert.Equal(Path.Combine(configDir.FullName, ".powerforge", "module-baseline.json"), prepared.PipelineSpec.Diagnostics.BaselinePath); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + private static JsonSerializerOptions CreateJsonOptions() { var options = new JsonSerializerOptions diff --git a/Schemas/powerforge.pipelinespec.schema.json b/Schemas/powerforge.pipelinespec.schema.json index 843d92a2..0715a354 100644 --- a/Schemas/powerforge.pipelinespec.schema.json +++ b/Schemas/powerforge.pipelinespec.schema.json @@ -22,6 +22,26 @@ "Roots": { "type": ["array", "null"], "items": { "type": "string" } } } }, + "Diagnostics": { + "type": "object", + "additionalProperties": false, + "properties": { + "BaselinePath": { "type": [ "string", "null" ] }, + "GenerateBaseline": { "type": "boolean" }, + "UpdateBaseline": { "type": "boolean" }, + "FailOnNewDiagnostics": { "type": "boolean" }, + "FailOnSeverity": { + "anyOf": [ + { "type": "string", "enum": [ "Info", "Warning", "Error" ] }, + { "type": "null" } + ] + }, + "BinaryConflictSearchRoots": { + "type": "array", + "items": { "type": "string" } + } + } + }, "Segments": { "type": "array", "items": { "$ref": "powerforge.segments.schema.json#/$defs/ConfigurationSegment" } From 5d9139ce6048354cbb806970e741a8358847b515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 21:26:27 +0200 Subject: [PATCH 05/16] Add bundle post-process signing --- ...SPublishModule.DotNetPublish.Quickstart.md | 1 + .../DotNetPublishPipelineRunnerBundleTests.cs | 95 +++++++++++++++++++ .../Models/DotNetPublish/DotNetPublishSpec.cs | 15 +++ .../DotNetPublishPipelineRunner.Bundle.cs | 89 +++++++++++++++++ .../DotNetPublishPipelineRunner.Plan.cs | 18 +++- Schemas/powerforge.dotnetpublish.schema.json | 7 ++ 6 files changed, 223 insertions(+), 2 deletions(-) diff --git a/Docs/PSPublishModule.DotNetPublish.Quickstart.md b/Docs/PSPublishModule.DotNetPublish.Quickstart.md index b41ccc96..7e444ccf 100644 --- a/Docs/PSPublishModule.DotNetPublish.Quickstart.md +++ b/Docs/PSPublishModule.DotNetPublish.Quickstart.md @@ -129,6 +129,7 @@ Depending on config, the run can emit: - `Scripts[]` let you run repo-specific finishing steps after the copy phase, for example exporting plugins, writing launchers, or producing metadata files. - `PostProcess.ArchiveDirectories[]` can zip bundle subdirectories after composition, which is useful for plugin packs or other nested payloads that should ship as archives. - `PostProcess.DeletePatterns[]` removes files or folders from the composed bundle using wildcard patterns such as `**/*.pdb` or `**/createdump.exe`. +- `PostProcess.SignProfile` / `PostProcess.Sign` signs files in the composed bundle before zip creation. Use `PostProcess.SignPatterns[]` for scripts and other bundle payloads, for example `**/*.exe`, `**/*.dll`, `**/*.ps1`, `**/*.psm1`, and `**/*.psd1`. - `PostProcess.Metadata` writes a JSON manifest from standard bundle properties plus templated custom values. - The same reusable post-process contract is also available outside the full publish pipeline through `powerforge dotnet bundle-postprocess` and `Invoke-PowerForgeBundlePostProcess`. - Standalone bundle post-process is useful when a repo keeps a thin PowerShell wrapper for product-specific launcher/readme generation but wants archive/delete/metadata mechanics to stay in PowerForge. diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs index 7ac290ac..5030b244 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs @@ -146,6 +146,101 @@ public void Plan_NormalizesBundlePackageLayoutPrimitives() } } + [Fact] + public void Plan_ResolvesBundlePostProcessSigningProfile() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + + var spec = CreateBaseSpec(root, app); + spec.SigningProfiles = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["evotec"] = new DotNetPublishSignOptions + { + Enabled = true, + IncludeDlls = true, + Thumbprint = "ABC123", + TimestampUrl = "http://timestamp.example" + } + }; + spec.Bundles = new[] + { + new DotNetPublishBundle + { + Id = "package", + PrepareFromTarget = "app", + PostProcess = new DotNetPublishBundlePostProcessOptions + { + SignProfile = " evotec ", + SignPatterns = new[] { " **/*.ps1 ", "**/*.dll" }, + SignOverrides = new DotNetPublishSignPatch + { + Description = "TierBridge Package" + } + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var postProcess = Assert.Single(plan.Bundles).PostProcess; + + Assert.NotNull(postProcess); + Assert.Equal(new[] { "**/*.ps1", "**/*.dll" }, postProcess!.SignPatterns); + Assert.NotNull(postProcess.Sign); + Assert.True(postProcess.Sign!.Enabled); + Assert.True(postProcess.Sign.IncludeDlls); + Assert.Equal("ABC123", postProcess.Sign.Thumbprint); + Assert.Equal("http://timestamp.example", postProcess.Sign.TimestampUrl); + Assert.Equal("TierBridge Package", postProcess.Sign.Description); + Assert.Null(postProcess.SignProfile); + Assert.Null(postProcess.SignOverrides); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void FindBundleSignTargets_MatchesNestedFilesAndExactPaths() + { + var root = CreateTempRoot(); + try + { + var bundleRoot = Directory.CreateDirectory(Path.Combine(root, "bundle")).FullName; + File.WriteAllText(Path.Combine(bundleRoot, "App.exe"), "app"); + File.WriteAllText(Path.Combine(bundleRoot, "README.md"), "readme"); + + var scripts = Directory.CreateDirectory(Path.Combine(bundleRoot, "Scripts")).FullName; + File.WriteAllText(Path.Combine(scripts, "Install-TierBridgeService.ps1"), "script"); + + var module = Directory.CreateDirectory(Path.Combine(bundleRoot, "Modules", "PowerTierBridge")).FullName; + File.WriteAllText(Path.Combine(module, "PowerTierBridge.psm1"), "module"); + File.WriteAllText(Path.Combine(module, "PowerTierBridge.psd1"), "manifest"); + + var lib = Directory.CreateDirectory(Path.Combine(module, "Lib", "Core")).FullName; + File.WriteAllText(Path.Combine(lib, "TierBridge.PowerShell.dll"), "dll"); + + var targets = DotNetPublishPipelineRunner.FindBundleSignTargets( + bundleRoot, + new[] { "**/*.ps1", "**/*.psm1", "**/*.psd1", "**/*.dll", "App.exe" }); + + Assert.Equal(5, targets.Length); + Assert.Contains(targets, path => path.EndsWith("App.exe", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("Install-TierBridgeService.ps1", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("PowerTierBridge.psm1", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("PowerTierBridge.psd1", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("TierBridge.PowerShell.dll", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(targets, path => path.EndsWith("README.md", StringComparison.OrdinalIgnoreCase)); + } + finally + { + TryDelete(root); + } + } + [Fact] public void BuildBundle_AppliesPostProcessArchiveDeleteAndMetadata() { diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs index c3ad1b07..0ced047b 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs @@ -555,6 +555,21 @@ public sealed class DotNetPublishBundlePostProcessOptions /// public string[] DeletePatterns { get; set; } = Array.Empty(); + /// + /// Optional wildcard patterns for files to sign relative to bundle root. + /// When omitted and signing is enabled, bundle signing targets executables plus DLLs when is true. + /// + public string[] SignPatterns { get; set; } = Array.Empty(); + + /// Optional named signing profile reference used when is not set. + public string? SignProfile { get; set; } + + /// Optional signing options applied to bundle files matched by . + public DotNetPublishSignOptions? Sign { get; set; } + + /// Optional partial overrides applied on top of . + public DotNetPublishSignPatch? SignOverrides { get; set; } + /// /// Optional metadata manifest emitted into the bundle after post-processing. /// diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs index 425f9fa5..be4b114e 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs @@ -143,6 +143,8 @@ internal DotNetPublishArtefactResult BuildBundle( SourceOutputPath = sourceArtefact.OutputDir, PostProcess = bundle.PostProcess }); + + SignBundlePostProcessFiles(plan, bundle, outputDir); } string? zipPath = null; @@ -421,4 +423,91 @@ private void RunPowerShellScript( if (!string.IsNullOrWhiteSpace(result.StdErr)) _logger.Verbose(result.StdErr.TrimEnd()); } } + + private void SignBundlePostProcessFiles( + DotNetPublishPlan plan, + DotNetPublishBundlePlan bundle, + string outputDir) + { + var sign = bundle.PostProcess?.Sign; + if (sign is null || !sign.Enabled) + return; + + var patterns = NormalizeBundleSignPatterns(bundle.PostProcess?.SignPatterns, sign); + var targets = FindBundleSignTargets(outputDir, patterns); + var signed = TrySignFiles( + targets, + outputDir, + sign, + scope: $"bundle '{bundle.Id}' files"); + + _logger.Info($"Bundle sign completed for '{bundle.Id}' -> {signed.Length}/{targets.Length} signed."); + } + + internal static string[] NormalizeBundleSignPatterns( + IReadOnlyList? patterns, + DotNetPublishSignOptions sign) + { + var normalized = (patterns ?? Array.Empty()) + .Where(pattern => !string.IsNullOrWhiteSpace(pattern)) + .Select(pattern => pattern.Trim().Replace('\\', '/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (normalized.Length > 0) + return normalized; + + var defaults = new List { "**/*.exe" }; + if (sign.IncludeDlls) + defaults.Add("**/*.dll"); + return defaults.ToArray(); + } + + internal static string[] FindBundleSignTargets(string bundleRoot, IReadOnlyList? patterns) + { + var root = Path.GetFullPath(bundleRoot); + if (!Directory.Exists(root)) + throw new DirectoryNotFoundException($"Bundle root was not found: {root}"); + + var matches = new List(); + foreach (var pattern in patterns ?? Array.Empty()) + { + var normalizedPattern = (pattern ?? string.Empty) + .Trim() + .Replace('\\', '/'); + if (normalizedPattern.Length == 0) + continue; + + var exactPath = ResolvePath(root, normalizedPattern); + if (File.Exists(exactPath)) + { + EnsurePathWithinRoot(root, exactPath, "Bundle signing target"); + matches.Add(exactPath); + continue; + } + + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var relative = GetRelativePath(root, file).Replace('\\', '/'); + if (BundleSignPatternMatches(relative, normalizedPattern)) + matches.Add(Path.GetFullPath(file)); + } + } + + return matches + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool BundleSignPatternMatches(string relativePath, string pattern) + { + if (WildcardMatch(relativePath, pattern)) + return true; + + if (pattern.StartsWith("**/", StringComparison.Ordinal)) + return WildcardMatch(relativePath, pattern.Substring("**/".Length)); + + return false; + } } diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs index 58143c37..eefd103a 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs @@ -1485,7 +1485,7 @@ private static DotNetPublishBundlePlan[] BuildBundlePlans( ModuleIncludes = moduleIncludePlans.ToArray(), GeneratedScripts = generatedScriptPlans.ToArray(), Scripts = scriptPlans.ToArray(), - PostProcess = NormalizeBundlePostProcess(id, bundle.PostProcess) + PostProcess = NormalizeBundlePostProcess(id, bundle.PostProcess, signingProfiles) }); } @@ -2215,7 +2215,8 @@ private static string BuildInstallerFilterSummary( private static DotNetPublishBundlePostProcessOptions? NormalizeBundlePostProcess( string bundleId, - DotNetPublishBundlePostProcessOptions? options) + DotNetPublishBundlePostProcessOptions? options, + IReadOnlyDictionary? signingProfiles) { var clone = CloneBundlePostProcessOptions(options); if (clone is null) @@ -2237,6 +2238,15 @@ private static string BuildInstallerFilterSummary( .ToArray(); clone.DeletePatterns = NormalizeStrings(clone.DeletePatterns); + clone.SignPatterns = NormalizeStrings(clone.SignPatterns); + clone.Sign = DotNetPublishSigningProfileResolver.ResolveConfiguredSignOptions( + signingProfiles, + clone.SignProfile, + clone.Sign, + clone.SignOverrides, + $"Bundle '{bundleId}' post-process signing"); + clone.SignProfile = null; + clone.SignOverrides = null; if (clone.Metadata is not null) { @@ -2344,6 +2354,10 @@ private static string BuildInstallerFilterSummary( }) .ToArray(), DeletePatterns = NormalizeStrings(options.DeletePatterns), + SignPatterns = NormalizeStrings(options.SignPatterns), + SignProfile = options.SignProfile, + Sign = DotNetPublishSigningProfileResolver.CloneSignOptions(options.Sign), + SignOverrides = DotNetPublishSigningProfileResolver.CloneSignPatch(options.SignOverrides), Metadata = options.Metadata is null ? null : new DotNetPublishBundleMetadataOptions diff --git a/Schemas/powerforge.dotnetpublish.schema.json b/Schemas/powerforge.dotnetpublish.schema.json index 14c50097..e9d2b7fc 100644 --- a/Schemas/powerforge.dotnetpublish.schema.json +++ b/Schemas/powerforge.dotnetpublish.schema.json @@ -513,6 +513,13 @@ "type": "array", "items": { "type": "string", "minLength": 1 } }, + "SignPatterns": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "SignProfile": { "type": ["string", "null"] }, + "Sign": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignOptions" }, { "type": "null" }] }, + "SignOverrides": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignPatch" }, { "type": "null" }] }, "Metadata": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishBundleMetadataOptions" }, { "type": "null" }] } } }, From b0867a2c7e351ade21c99eb3c7f50c1e9b245875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 21:41:47 +0200 Subject: [PATCH 06/16] Fix module formatting line ending normalization --- Build/Build-Module.ps1 | 3 + .../LineEndingsNormalizerTests.cs | 55 +++++++++++++++++++ PowerForge/Services/FormattingPipeline.cs | 16 ++++-- PowerForge/Services/LineEndingsNormalizer.cs | 11 +--- 4 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 PowerForge.Tests/LineEndingsNormalizerTests.cs diff --git a/Build/Build-Module.ps1 b/Build/Build-Module.ps1 index f05f2293..746f2c4f 100644 --- a/Build/Build-Module.ps1 +++ b/Build/Build-Module.ps1 @@ -24,3 +24,6 @@ if ($NoBuild) { $invoke.NoBuild = $true } if ($Json) { $invoke.Json = $true } & $script @invoke +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/PowerForge.Tests/LineEndingsNormalizerTests.cs b/PowerForge.Tests/LineEndingsNormalizerTests.cs new file mode 100644 index 00000000..e029c688 --- /dev/null +++ b/PowerForge.Tests/LineEndingsNormalizerTests.cs @@ -0,0 +1,55 @@ +using System.Text; + +namespace PowerForge.Tests; + +public sealed class LineEndingsNormalizerTests +{ + [Fact] + public void NormalizeFile_ToCrLf_ConvertsBareCrAndLf() + { + var path = Path.Combine(Path.GetTempPath(), $"powerforge-lineendings-{Guid.NewGuid():N}.ps1"); + try + { + File.WriteAllText(path, "one\ntwo\rthree\r\nfour", new UTF8Encoding(false)); + + var result = new LineEndingsNormalizer().NormalizeFile( + path, + new NormalizationOptions(LineEnding.CRLF, ensureUtf8Bom: false)); + + var text = File.ReadAllText(path, new UTF8Encoding(false)); + + Assert.True(result.Changed); + Assert.Equal("one\r\ntwo\r\nthree\r\nfour", text); + Assert.DoesNotContain("\rt", text); + Assert.DoesNotContain("e\nf", text); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void NormalizeFile_ToLf_ConvertsBareCrAndCrLf() + { + var path = Path.Combine(Path.GetTempPath(), $"powerforge-lineendings-{Guid.NewGuid():N}.ps1"); + try + { + File.WriteAllText(path, "one\r\ntwo\rthree", new UTF8Encoding(false)); + + var result = new LineEndingsNormalizer().NormalizeFile( + path, + new NormalizationOptions(LineEnding.LF, ensureUtf8Bom: false)); + + var text = File.ReadAllText(path, new UTF8Encoding(false)); + + Assert.True(result.Changed); + Assert.Equal("one\ntwo\nthree", text); + Assert.DoesNotContain('\r', text); + } + finally + { + File.Delete(path); + } + } +} diff --git a/PowerForge/Services/FormattingPipeline.cs b/PowerForge/Services/FormattingPipeline.cs index 95e536b6..66fced6a 100644 --- a/PowerForge/Services/FormattingPipeline.cs +++ b/PowerForge/Services/FormattingPipeline.cs @@ -31,6 +31,14 @@ public IReadOnlyList Run(IEnumerable files, FormatOptio var list = files.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); if (list.Length == 0) return Array.Empty(); + // Step 0: normalize first so PSScriptAnalyzer can parse files that have mixed line endings. + var opts = new NormalizationOptions(options.LineEnding, options.Utf8Bom); + var preNormalize = new List(list.Length); + foreach (var f in list) + { + preNormalize.Add(_norm.NormalizeFile(f, opts)); + } + // Step 1: Preprocess var pre = _pre.Process(list, options); @@ -39,18 +47,19 @@ public IReadOnlyList Run(IEnumerable files, FormatOptio // Step 3: Normalize var results = new List(list.Length); - var opts = new NormalizationOptions(options.LineEnding, options.Utf8Bom); foreach (var f in list) { var n = _norm.NormalizeFile(f, opts); + var preNormalizeResult = preNormalize.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); var preResult = pre.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); var pssaResult = pssa.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); + bool preNormalizeChanged = preNormalizeResult?.Changed ?? false; bool preChanged = preResult?.Changed ?? false; bool pssaChanged = pssaResult?.Changed ?? false; - bool changed = preChanged || pssaChanged || n.Changed; + bool changed = preNormalizeChanged || preChanged || pssaChanged || n.Changed; - var details = $"pre={(preChanged ? '1' : '0')}; pssa={(pssaChanged ? '1' : '0')}; norm={(n.Changed ? '1' : '0')}"; + var details = $"preNorm={(preNormalizeChanged ? '1' : '0')}; pre={(preChanged ? '1' : '0')}; pssa={(pssaChanged ? '1' : '0')}; norm={(n.Changed ? '1' : '0')}"; var preMsg = preResult?.Message ?? string.Empty; var pssaMsg = pssaResult?.Message ?? string.Empty; @@ -67,4 +76,3 @@ public IReadOnlyList Run(IEnumerable files, FormatOptio return results; } } - diff --git a/PowerForge/Services/LineEndingsNormalizer.cs b/PowerForge/Services/LineEndingsNormalizer.cs index f614fdcf..9fd8d72f 100644 --- a/PowerForge/Services/LineEndingsNormalizer.cs +++ b/PowerForge/Services/LineEndingsNormalizer.cs @@ -63,17 +63,12 @@ private static string DetectDominant(string text) /// private static string NormalizeEndings(string text, string target) { - // Fast path: already consistent if (target == "\r\n") { - // Replace bare LF not preceded by CR - return text.Replace("\r\n", "\n").Replace("\n", "\r\n"); - } - else - { - // Target LF: remove CRs before LFs - return text.Replace("\r\n", "\n"); + return text.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n"); } + + return text.Replace("\r\n", "\n").Replace("\r", "\n"); } /// From 6bc01d498377369615e60abe0d2e4b20b4ea1328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 22:22:31 +0200 Subject: [PATCH 07/16] Address publish bundle review feedback --- CHANGELOG.MD | 5 +- ...SPublishModule.DotNetPublish.Quickstart.md | 6 + ...owerForge.BuildReuse.TestimoXTierBridge.md | 3 - .../Models/ModuleBuildPreparedContext.cs | 1 + .../Services/ModuleBuildPreparationService.cs | 3 +- ...blishPipelineRunnerBundleHardeningTests.cs | 152 ++++++++++++++++++ .../DotNetPublishPipelineRunnerBundleTests.cs | 20 +-- .../DotNetPublishPipelineRunnerHookTests.cs | 68 ++++++++ ...NetPublishPipelineRunnerMsiPrepareTests.cs | 7 +- PowerForge.Tests/ExportDetectorTests.cs | 15 +- .../ModuleBuildPreparationServiceTests.cs | 3 +- .../PowerForgePluginCatalogServiceTests.cs | 15 +- PowerForge.Tests/RepoRootLocator.cs | 19 +++ .../DotNetPublishPipelineRunner.Bundle.cs | 15 +- .../DotNetPublishPipelineRunner.Hooks.cs | 46 +++++- .../DotNetPublishPipelineRunner.Plan.cs | 13 +- Schemas/powerforge.dotnetpublish.schema.json | 39 ++++- 17 files changed, 357 insertions(+), 73 deletions(-) create mode 100644 PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs create mode 100644 PowerForge.Tests/RepoRootLocator.cs diff --git a/CHANGELOG.MD b/CHANGELOG.MD index f4bbbe3d..1198ca11 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,5 +1,8 @@ # PSPublishModule Changelog +## Unreleased +- DotNet publish CLI overrides (`--target`, `--runtime`, `--framework`, `--style`) are applied before profile resolution so profile-filtered plan/export output matches the executed run. + ## 2.0.19 - 2025.06.17 ### What's Changed * Improve error handling in release utilities by @PrzemyslawKlys in https://github.com/EvotecIT/PSPublishModule/pull/32 @@ -221,4 +224,4 @@ I've removed the logic where Standard would always get loaded even if Default/Co ## 0.9.43 - 2022.04.14 - Small fixes for publishing modules with Standard Libraries only -- Improved building of Artefacts \ No newline at end of file +- Improved building of Artefacts diff --git a/Docs/PSPublishModule.DotNetPublish.Quickstart.md b/Docs/PSPublishModule.DotNetPublish.Quickstart.md index 7e444ccf..4a9fb0b8 100644 --- a/Docs/PSPublishModule.DotNetPublish.Quickstart.md +++ b/Docs/PSPublishModule.DotNetPublish.Quickstart.md @@ -52,6 +52,8 @@ Invoke-DotNetPublish ` -ExitCode ``` +CLI overrides are applied before profile resolution. When a profile is active, `-Target`, `-Runtimes`, `-Frameworks`, and `-Styles` constrain the active profile and matching targets so the exported/plan view reflects the same inputs the runner executes. + ## DSL Flow (When You Want Scripted Composition) ```powershell @@ -126,6 +128,8 @@ Depending on config, the run can emit: - `CopyItems[]` copies non-publish files or directories such as README files, static scripts, licenses, or product data into the bundle. - `ModuleIncludes[]` copies built PowerShell module artefacts into the bundle, defaulting to `Modules/{moduleName}` so apps can ship their companion module without scraping user-profile installs. - `GeneratedScripts[]` renders inline or file-based script templates into the bundle. Template values use `{{TokenName}}`; token values can use bundle tokens such as `{output}`, `{rid}`, `{framework}`, and `{moduleName}` for module includes. +- Bundle paths and token values use single braces such as `{output}`. Generated script template bodies use double braces such as `{{ModuleName}}`; `GeneratedScripts[].Tokens` bridges the two by resolving single-brace values first and then making them available to the double-brace template renderer. +- `GeneratedScripts[].Overwrite=false` means "fail if the target file already exists", not "skip if present". `CopyItems[].ClearDestination=false` and `ModuleIncludes[].ClearDestination=false` likewise preserve existing destinations by failing on copy conflicts. - `Scripts[]` let you run repo-specific finishing steps after the copy phase, for example exporting plugins, writing launchers, or producing metadata files. - `PostProcess.ArchiveDirectories[]` can zip bundle subdirectories after composition, which is useful for plugin packs or other nested payloads that should ship as archives. - `PostProcess.DeletePatterns[]` removes files or folders from the composed bundle using wildcard patterns such as `**/*.pdb` or `**/createdump.exe`. @@ -151,6 +155,7 @@ Depending on config, the run can emit: - Supported phases are `BeforeRestore`, `BeforeBuild`, `BeforeTargetPublish`, `AfterTargetPublish`, `BeforeBundle`, and `AfterBundle`. - Hooks support target/runtime/framework/style filters, arguments, working directory, environment variables, timeout, and required/optional failure policy. - Hook arguments and environment values support tokens such as `{projectRoot}`, `{configuration}`, `{target}`, `{rid}`, `{framework}`, `{style}`, `{bundle}`, `{phase}`, and `{hook}`. +- Hook timeout defaults to 600 seconds when omitted; set `TimeoutSeconds` lower for quick local validation hooks or higher for intentionally long-running packaging steps. - Use `BeforeBuild` or `BeforeTargetPublish` for generated-source/catalog refreshes, and `BeforeBundle` or `AfterBundle` for product-specific package finishing that should stay repo-owned. ## MSI From Bundle @@ -158,6 +163,7 @@ Depending on config, the run can emit: - Use `PrepareFromBundleId` on an installer when WiX harvesting should run against the composed portable bundle instead of the raw `PrepareFromTarget` publish folder. - This is the right pattern for desktop apps that need sidecars, plugins, helper scripts, or metadata to be present in the installer payload. - Keep `PrepareFromTarget` set as well; it remains the source combination that installer filters validate against. +- `HarvestExcludePatterns[]` accepts relative wildcard paths such as `**/*.pdb` and basename globs such as `createdump.exe`; basename globs apply anywhere under the harvested payload. ## Microsoft Store / MSIX Packaging diff --git a/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md b/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md index cbeefcb7..f4f74eb5 100644 --- a/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md +++ b/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md @@ -1,8 +1,5 @@ # PowerForge Reusable Build Coverage: TestimoX and TierBridge -Date: 2026-04-28 -Branch/worktree: `codex/catalog-reuse` in `C:\Support\GitHub\PSPublishModule-codex-catalog-reuse` - This note maps the PowerShell-heavy build/deploy/catalog logic in TestimoX and TierBridge to the reusable capabilities already present in PSPublishModule/PowerForge, then proposes the smallest engine changes needed to make future repo scripts thin wrappers. ## Consumer Inventory diff --git a/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs b/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs index 725064d6..2efea1ea 100644 --- a/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs +++ b/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs @@ -9,4 +9,5 @@ internal sealed class ModuleBuildPreparedContext public ModulePipelineSpec PipelineSpec { get; set; } = new(); public string? JsonOutputPath { get; set; } public string ConfigLabel { get; set; } = "cmdlet"; + public string? ConfigFilePath { get; set; } } diff --git a/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs b/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs index 52f677ee..76f6bd41 100644 --- a/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs +++ b/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs @@ -122,7 +122,8 @@ private ModuleBuildPreparedContext PrepareFromConfig(ModuleBuildPreparationReque UseLegacy = false, PipelineSpec = spec, JsonOutputPath = request.JsonOnly ? ResolveJsonOutputPath(request, spec.Build.SourcePath) : null, - ConfigLabel = configFullPath + ConfigLabel = "json", + ConfigFilePath = configFullPath }; } diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs new file mode 100644 index 00000000..8b6a8006 --- /dev/null +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs @@ -0,0 +1,152 @@ +namespace PowerForge.Tests; + +public sealed class DotNetPublishPipelineRunnerBundleHardeningTests +{ + [Fact] + public void BuildBundle_RejectsGeneratedScriptTemplateOutsideProjectRoot() + { + var root = CreateTempRoot(); + var outsideRoot = Path.GetFullPath(Path.Combine(root, "..", Path.GetFileName(root) + "-outside")); + try + { + Directory.CreateDirectory(outsideRoot); + File.WriteAllText(Path.Combine(outsideRoot, "Install.ps1.template"), "{{CommandName}}"); + + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + GeneratedScripts = new[] + { + new DotNetPublishBundleGeneratedScriptPlan + { + TemplatePath = Path.Combine("..", Path.GetFileName(outsideRoot), "Install.ps1.template"), + OutputPath = "Install.ps1", + Tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CommandName"] = "Install-App" + } + } + } + }); + + var ex = Assert.Throws(() => BuildBundle(plan, publishDir)); + Assert.Contains("outside ProjectRoot", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + TryDelete(outsideRoot); + } + } + + [Fact] + public void BuildBundle_CopyItemThrowsWhenDestinationExistsAndClearDestinationDisabled() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var readmePath = Path.Combine(root, "Build", "README.package.md"); + Directory.CreateDirectory(Path.GetDirectoryName(readmePath)!); + File.WriteAllText(readmePath, "# Package"); + + var outputDir = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Bundles", "package")).FullName; + File.WriteAllText(Path.Combine(outputDir, "README.md"), "# Existing"); + + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + ClearOutput = false, + CopyItems = new[] + { + new DotNetPublishBundleCopyItemPlan + { + SourcePath = "Build/README.package.md", + DestinationPath = "README.md", + ClearDestination = false + } + } + }); + + Assert.Throws(() => BuildBundle(plan, publishDir, outputDir)); + } + finally + { + TryDelete(root); + } + } + + private static DotNetPublishPlan CreatePlan(string root, DotNetPublishBundlePlan bundle) + { + return new DotNetPublishPlan + { + ProjectRoot = root, + Bundles = new[] { bundle } + }; + } + + private static DotNetPublishArtefactResult BuildBundle( + DotNetPublishPlan plan, + string publishDir, + string? outputDir = null) + { + outputDir ??= Path.Combine(plan.ProjectRoot, "Artifacts", "Bundles", "package"); + var artefacts = new[] + { + new DotNetPublishArtefactResult + { + Category = DotNetPublishArtefactCategory.Publish, + Target = "app", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.PortableCompat, + OutputDir = publishDir, + PublishDir = publishDir + } + }; + var step = new DotNetPublishStep + { + Key = "bundle:package:app:net10.0:win-x64:PortableCompat", + Kind = DotNetPublishStepKind.Bundle, + BundleId = "package", + TargetName = "app", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.PortableCompat, + BundleOutputPath = outputDir + }; + + return new DotNetPublishPipelineRunner(new NullLogger()).BuildBundle(plan, artefacts, step); + } + + private static string CreateTempRoot() + { + var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + // best effort + } + } +} diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs index 5030b244..b791fb1e 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs @@ -13,7 +13,7 @@ public sealed class DotNetPublishPipelineRunnerBundleTests [Fact] public void ExamplePackageBundleMsi_DeserializesPackageLayoutPrimitives() { - var repoRoot = FindRepoRoot(); + var repoRoot = RepoRootLocator.Find(); var examplePath = Path.Combine(repoRoot, "Module", "Examples", "DotNetPublish", "Example.PackageBundleMsi.json"); Assert.True(File.Exists(examplePath), $"Example file not found: {examplePath}"); @@ -211,6 +211,7 @@ public void FindBundleSignTargets_MatchesNestedFilesAndExactPaths() { var bundleRoot = Directory.CreateDirectory(Path.Combine(root, "bundle")).FullName; File.WriteAllText(Path.Combine(bundleRoot, "App.exe"), "app"); + File.WriteAllText(Path.Combine(bundleRoot, "RootLibrary.dll"), "dll"); File.WriteAllText(Path.Combine(bundleRoot, "README.md"), "readme"); var scripts = Directory.CreateDirectory(Path.Combine(bundleRoot, "Scripts")).FullName; @@ -227,8 +228,9 @@ public void FindBundleSignTargets_MatchesNestedFilesAndExactPaths() bundleRoot, new[] { "**/*.ps1", "**/*.psm1", "**/*.psd1", "**/*.dll", "App.exe" }); - Assert.Equal(5, targets.Length); + Assert.Equal(6, targets.Length); Assert.Contains(targets, path => path.EndsWith("App.exe", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("RootLibrary.dll", StringComparison.OrdinalIgnoreCase)); Assert.Contains(targets, path => path.EndsWith("Install-TierBridgeService.ps1", StringComparison.OrdinalIgnoreCase)); Assert.Contains(targets, path => path.EndsWith("PowerTierBridge.psm1", StringComparison.OrdinalIgnoreCase)); Assert.Contains(targets, path => path.EndsWith("PowerTierBridge.psd1", StringComparison.OrdinalIgnoreCase)); @@ -736,20 +738,6 @@ private static string CreateTempRoot() return root; } - private static string FindRepoRoot() - { - var current = new DirectoryInfo(AppContext.BaseDirectory); - for (var i = 0; i < 12 && current is not null; i++) - { - var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); - if (File.Exists(marker)) - return current.FullName; - current = current.Parent; - } - - throw new DirectoryNotFoundException("Unable to locate repository root for dotnet publish bundle tests."); - } - private static void TryDelete(string path) { try diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs index 1d832789..ff7c4373 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs @@ -89,6 +89,74 @@ public void Plan_AddsCommandHooksAroundPublishAndBundleSteps() } } + [Fact] + public void Plan_CommandHooksUseContextSpecificKeysAndDefaultTimeout() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var worker = CreateProject(root, "Worker/Worker.csproj"); + var spec = new DotNetPublishSpec + { + DotNet = new DotNetPublishDotNetOptions + { + ProjectRoot = root, + Restore = false, + Build = false, + Runtimes = new[] { "win-x64" } + }, + Targets = new[] + { + new DotNetPublishTarget + { + Name = "App", + ProjectPath = app, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + }, + new DotNetPublishTarget + { + Name = "Worker", + ProjectPath = worker, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + } + }, + Hooks = new[] + { + new DotNetPublishCommandHook + { + Id = "catalog", + Phase = DotNetPublishCommandHookPhase.BeforeTargetPublish, + Command = "dotnet" + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var hookSteps = plan.Steps + .Where(step => step.Kind == DotNetPublishStepKind.CommandHook) + .ToArray(); + + Assert.Equal(2, hookSteps.Length); + Assert.Equal(2, hookSteps.Select(step => step.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.All(hookSteps, step => Assert.Equal(600, step.HookTimeoutSeconds)); + } + finally + { + TryDelete(root); + } + } + [Fact] public void RunCommandHook_ExpandsArgumentsWorkingDirectoryAndEnvironment() { diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs index 172c4253..8a4311c4 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs @@ -294,6 +294,9 @@ public void PrepareMsiPackage_ExcludesConfiguredHarvestPatterns() File.WriteAllText(Path.Combine(outputDir, "Install-Service.ps1"), "install"); Directory.CreateDirectory(Path.Combine(outputDir, "data")); File.WriteAllText(Path.Combine(outputDir, "data", "settings.json"), "{ }"); + Directory.CreateDirectory(Path.Combine(outputDir, "data", "deep")); + File.WriteAllText(Path.Combine(outputDir, "data", "deep", "symbols.pdb"), "symbols"); + File.WriteAllText(Path.Combine(outputDir, "data", "deep", "createdump.exe"), "diag"); var stagingPath = Path.Combine(root, "Artifacts", "Msi", "svc.msi", "payload"); var manifestPath = Path.Combine(root, "Artifacts", "Msi", "svc.msi", "prepare.manifest.json"); @@ -325,7 +328,7 @@ public void PrepareMsiPackage_ExcludesConfiguredHarvestPatterns() new DotNetPublishInstallerPlan { Id = "svc.msi", - HarvestExcludePatterns = new[] { "Svc.exe", "**/Install-Service.ps1" } + HarvestExcludePatterns = new[] { "Svc.exe", "**/Install-Service.ps1", "**/*.pdb", "createdump.exe" } } } }; @@ -352,6 +355,8 @@ public void PrepareMsiPackage_ExcludesConfiguredHarvestPatterns() var wxs = File.ReadAllText(result!.HarvestPath!); Assert.DoesNotContain("Svc.exe", wxs, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("Install-Service.ps1", wxs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("symbols.pdb", wxs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("createdump.exe", wxs, StringComparison.OrdinalIgnoreCase); Assert.Contains("settings.json", wxs, StringComparison.OrdinalIgnoreCase); } finally diff --git a/PowerForge.Tests/ExportDetectorTests.cs b/PowerForge.Tests/ExportDetectorTests.cs index e8e33902..07ea6834 100644 --- a/PowerForge.Tests/ExportDetectorTests.cs +++ b/PowerForge.Tests/ExportDetectorTests.cs @@ -39,7 +39,7 @@ public void DetectBinaryCmdlets_finds_PSPublishModule_plugin_and_bundle_cmdlets( [Fact] public void GeneratedModuleFiles_export_PSPublishModule_plugin_and_bundle_cmdlets() { - var repoRoot = FindRepoRoot(); + var repoRoot = RepoRootLocator.Find(); var manifestPath = Path.Combine(repoRoot, "Module", "PSPublishModule.psd1"); var bootstrapperPath = Path.Combine(repoRoot, "Module", "PSPublishModule.psm1"); @@ -89,17 +89,4 @@ private sealed class GetExampleCommand : PSCmdlet { } - private static string FindRepoRoot() - { - var current = new DirectoryInfo(AppContext.BaseDirectory); - for (var i = 0; i < 12 && current is not null; i++) - { - var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); - if (File.Exists(marker)) - return current.FullName; - current = current.Parent; - } - - throw new DirectoryNotFoundException("Unable to locate repository root for export detector tests."); - } } diff --git a/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs b/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs index ebbd25d2..cfb7ac69 100644 --- a/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs +++ b/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs @@ -356,7 +356,8 @@ public void Prepare_from_config_loads_pipeline_json_and_resolves_paths() Assert.Equal(moduleRoot.FullName, prepared.ProjectRoot); Assert.False(prepared.UseLegacy); Assert.Null(prepared.BasePathForScaffold); - Assert.Equal(configPath, prepared.ConfigLabel); + Assert.Equal("json", prepared.ConfigLabel); + Assert.Equal(configPath, prepared.ConfigFilePath); Assert.Equal(Path.Combine(root.FullName, "Build", "Artifacts", "Module", "Staging"), prepared.PipelineSpec.Build.StagingPath); Assert.Equal(Path.Combine(root.FullName, "TierBridge.PowerShell", "TierBridge.PowerShell.csproj"), prepared.PipelineSpec.Build.CsprojPath); Assert.Equal(Path.Combine(configDir.FullName, ".powerforge", "module-baseline.json"), prepared.PipelineSpec.Diagnostics.BaselinePath); diff --git a/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs b/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs index 9d376249..c349f7b8 100644 --- a/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs +++ b/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs @@ -9,7 +9,7 @@ public sealed class PowerForgePluginCatalogServiceTests [Fact] public void ExampleCatalog_deserializes_to_plugin_catalog_spec() { - var repoRoot = FindRepoRoot(); + var repoRoot = RepoRootLocator.Find(); var examplePath = Path.Combine(repoRoot, "Module", "Examples", "PluginCatalog", "powerforge.plugins.json"); Assert.True(File.Exists(examplePath), $"Plugin catalog example not found: {examplePath}"); @@ -478,17 +478,4 @@ private static void TryDelete(string path) File.Delete(path); } - private static string FindRepoRoot() - { - var current = new DirectoryInfo(AppContext.BaseDirectory); - for (var i = 0; i < 12 && current is not null; i++) - { - var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); - if (File.Exists(marker)) - return current.FullName; - current = current.Parent; - } - - throw new DirectoryNotFoundException("Unable to locate repository root for plugin catalog tests."); - } } diff --git a/PowerForge.Tests/RepoRootLocator.cs b/PowerForge.Tests/RepoRootLocator.cs new file mode 100644 index 00000000..c27fc7ef --- /dev/null +++ b/PowerForge.Tests/RepoRootLocator.cs @@ -0,0 +1,19 @@ +namespace PowerForge.Tests; + +internal static class RepoRootLocator +{ + public static string Find() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var i = 0; i < 12 && current is not null; i++) + { + var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); + if (File.Exists(marker)) + return current.FullName; + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Unable to locate repository root for PowerForge tests."); + } +} diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs index be4b114e..c4258b25 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs @@ -253,9 +253,10 @@ private void CopyBundleModules( { if (module is null) continue; - var moduleTokens = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var token in tokens) - moduleTokens[token.Key] = token.Value ?? string.Empty; + var moduleTokens = tokens.ToDictionary( + token => token.Key, + token => token.Value, + StringComparer.OrdinalIgnoreCase); moduleTokens["moduleName"] = module.ModuleName; var source = ResolvePath(plan.ProjectRoot, ApplyTemplate(module.SourcePath, moduleTokens)); var destination = ResolvePath(outputDir, ApplyTemplate(module.DestinationPath, moduleTokens)); @@ -285,15 +286,17 @@ private void GenerateBundleScripts( if (!string.IsNullOrWhiteSpace(script.TemplatePath)) { var templatePath = ResolvePath(plan.ProjectRoot, ApplyTemplate(script.TemplatePath!, tokens)); + EnsurePathWithinRoot(plan.ProjectRoot, templatePath, $"Bundle '{bundle.Id}' generated script template path"); if (!File.Exists(templatePath)) throw new FileNotFoundException($"Generated script template not found for bundle '{bundle.Id}': {templatePath}", templatePath); templateName = templatePath; template = File.ReadAllText(templatePath, Encoding.UTF8); } - var renderTokens = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var token in tokens) - renderTokens[token.Key] = token.Value ?? string.Empty; + var renderTokens = tokens.ToDictionary( + token => token.Key, + token => token.Value, + StringComparer.OrdinalIgnoreCase); foreach (var token in script.Tokens ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) renderTokens[token.Key] = ApplyTemplate(token.Value ?? string.Empty, tokens); diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs index 0d0365cf..01c79888 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs @@ -82,7 +82,7 @@ internal void RunCommandHook(DotNetPublishPlan plan, DotNetPublishStep step) private static string ResolveHookCommandPath(string projectRoot, string command) { - var raw = (command ?? string.Empty).Trim().Trim('"'); + var raw = TrimMatchingQuotes((command ?? string.Empty).Trim()); if (string.IsNullOrWhiteSpace(raw)) return raw; if (Path.IsPathRooted(raw)) @@ -92,7 +92,15 @@ private static string ResolveHookCommandPath(string projectRoot, string command) return raw; } - private static (int ExitCode, string StdOut, string StdErr, string Executable) RunHookProcess( + private static string TrimMatchingQuotes(string value) + { + if (value.Length >= 2 && value[0] == '"' && value[value.Length - 1] == '"') + return value.Substring(1, value.Length - 2); + + return value; + } + + private (int ExitCode, string StdOut, string StdErr, string Executable) RunHookProcess( string fileName, string workingDirectory, IReadOnlyList args, @@ -128,9 +136,30 @@ private static (int ExitCode, string StdOut, string StdErr, string Executable) R if (!process.WaitForExit(timeoutMs)) { - try { process.Kill(); } catch { /* best effort */ } - var stdout = stdoutTask.GetAwaiter().GetResult(); - var stderr = stderrTask.GetAwaiter().GetResult(); + try + { +#if NET472 + process.Kill(); +#else + process.Kill(entireProcessTree: true); +#endif + } + catch (Exception ex) + { + _logger.Verbose($"Hook timeout kill failed for '{fileName}': {ex.Message}"); + } + + try + { + Task.WhenAll(stdoutTask, stderrTask).Wait(TimeSpan.FromSeconds(5)); + } + catch + { + // Best effort after timeout; return whatever stream data completed. + } + + var stdout = TryGetCompletedOutput(stdoutTask); + var stderr = TryGetCompletedOutput(stderrTask); return (-1, stdout, stderr, fileName); } @@ -140,4 +169,11 @@ private static (int ExitCode, string StdOut, string StdErr, string Executable) R stderrTask.GetAwaiter().GetResult(), fileName); } + + private static string TryGetCompletedOutput(Task task) + { + return task.IsCompleted && !task.IsFaulted && !task.IsCanceled + ? task.GetAwaiter().GetResult() + : string.Empty; + } } diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs index eefd103a..390e2d47 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs @@ -8,6 +8,8 @@ namespace PowerForge; public sealed partial class DotNetPublishPipelineRunner { + private const int DefaultCommandHookTimeoutSeconds = 600; + private const string DefaultMsiPrepareStagingPathTemplate = "Artifacts/DotNetPublish/Msi/{installer}/{target}/{rid}/{framework}/{style}/payload"; @@ -770,7 +772,7 @@ private static DotNetPublishBundle[] CloneBundles(DotNetPublishBundle[] bundles) ModuleIncludes = CloneBundleModuleIncludes(b.ModuleIncludes), GeneratedScripts = CloneBundleGeneratedScripts(b.GeneratedScripts), Scripts = CloneBundleScripts(b.Scripts), - PostProcess = b.PostProcess + PostProcess = CloneBundlePostProcessOptions(b.PostProcess) }) .ToArray(); } @@ -1149,7 +1151,6 @@ private static string[] NormalizeStrings(IEnumerable? values) private static string[] NormalizeArguments(IEnumerable? values) { return (values ?? Array.Empty()) - .Where(v => v is not null) .Select(v => v ?? string.Empty) .ToArray(); } @@ -1184,7 +1185,9 @@ private static DotNetPublishCommandHook[] NormalizeCommandHooks(IEnumerable(StringComparer.OrdinalIgnoreCase), - HookTimeoutSeconds = Math.Max(1, hook.TimeoutSeconds), + HookTimeoutSeconds = hook.TimeoutSeconds <= 0 + ? DefaultCommandHookTimeoutSeconds + : Math.Max(1, hook.TimeoutSeconds), HookRequired = hook.Required, TargetName = targetName, Framework = framework, diff --git a/Schemas/powerforge.dotnetpublish.schema.json b/Schemas/powerforge.dotnetpublish.schema.json index e9d2b7fc..e2b2d23a 100644 --- a/Schemas/powerforge.dotnetpublish.schema.json +++ b/Schemas/powerforge.dotnetpublish.schema.json @@ -366,7 +366,11 @@ "HarvestPath": { "type": ["string", "null"] }, "HarvestDirectoryRefId": { "type": ["string", "null"] }, "HarvestComponentGroupId": { "type": ["string", "null"] }, - "HarvestExcludePatterns": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "HarvestExcludePatterns": { + "type": "array", + "description": "Relative wildcard or basename patterns excluded from WiX harvest. Bare basenames match anywhere under the staged payload.", + "items": { "type": "string", "minLength": 1 } + }, "Versioning": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishMsiVersionOptions" }, { "type": "null" }] }, "MsBuildProperties": { "type": ["object", "null"], @@ -426,7 +430,10 @@ "SourcePath": { "type": "string", "minLength": 1 }, "DestinationPath": { "type": "string", "minLength": 1 }, "Required": { "type": "boolean" }, - "ClearDestination": { "type": "boolean" } + "ClearDestination": { + "type": "boolean", + "description": "When false, an existing destination is preserved by failing the copy instead of replacing it." + } }, "required": ["SourcePath", "DestinationPath"] }, @@ -438,7 +445,10 @@ "SourcePath": { "type": "string", "minLength": 1 }, "DestinationPath": { "type": ["string", "null"] }, "Required": { "type": "boolean" }, - "ClearDestination": { "type": "boolean" } + "ClearDestination": { + "type": "boolean", + "description": "When false, an existing module destination is preserved by failing the copy instead of replacing it." + } }, "required": ["ModuleName", "SourcePath"] }, @@ -446,14 +456,21 @@ "type": "object", "additionalProperties": false, "properties": { - "TemplatePath": { "type": ["string", "null"] }, + "TemplatePath": { + "type": ["string", "null"], + "description": "Template file path resolved under ProjectRoot. Path tokens use single braces, for example {output}." + }, "Template": { "type": ["string", "null"] }, "OutputPath": { "type": "string", "minLength": 1 }, "Tokens": { "type": ["object", "null"], + "description": "Template token values. Values are expanded with single-brace bundle tokens before being exposed to double-brace placeholders in the generated script body.", "additionalProperties": { "type": "string" } }, - "Overwrite": { "type": "boolean" }, + "Overwrite": { + "type": "boolean", + "description": "When false, an existing generated script is an error rather than a silent skip." + }, "SignProfile": { "type": ["string", "null"] }, "Sign": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignOptions" }, { "type": "null" }] }, "SignOverrides": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignPatch" }, { "type": "null" }] } @@ -471,7 +488,11 @@ "Path": { "type": "string", "minLength": 1 }, "Arguments": { "type": "array", "items": { "type": "string" } }, "WorkingDirectory": { "type": ["string", "null"] }, - "TimeoutSeconds": { "type": "integer", "minimum": 1 }, + "TimeoutSeconds": { + "type": "integer", + "minimum": 1, + "description": "Maximum bundle script runtime in seconds. Defaults to 600 when omitted." + }, "PreferPwsh": { "type": "boolean" }, "Required": { "type": "boolean" } }, @@ -578,7 +599,11 @@ "type": ["object", "null"], "additionalProperties": { "type": "string" } }, - "TimeoutSeconds": { "type": "integer", "minimum": 1 }, + "TimeoutSeconds": { + "type": "integer", + "minimum": 1, + "description": "Maximum hook runtime in seconds. Defaults to 600 when omitted." + }, "Required": { "type": "boolean" }, "Targets": { "type": "array", From a3f29a17e2cc3e0af56b54e7cc2e117bbb3cfef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 22:31:51 +0200 Subject: [PATCH 08/16] Clarify bundle hook review followups --- ...SPublishModule.DotNetPublish.Quickstart.md | 3 ++- PowerForge.Cli/Program.Helpers.RunAndParse.cs | 3 +++ .../DotNetPublishPipelineRunnerHookTests.cs | 27 +++++++++++++++++++ Schemas/powerforge.dotnetpublish.schema.json | 4 +-- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Docs/PSPublishModule.DotNetPublish.Quickstart.md b/Docs/PSPublishModule.DotNetPublish.Quickstart.md index 4a9fb0b8..664a8dee 100644 --- a/Docs/PSPublishModule.DotNetPublish.Quickstart.md +++ b/Docs/PSPublishModule.DotNetPublish.Quickstart.md @@ -155,7 +155,8 @@ Depending on config, the run can emit: - Supported phases are `BeforeRestore`, `BeforeBuild`, `BeforeTargetPublish`, `AfterTargetPublish`, `BeforeBundle`, and `AfterBundle`. - Hooks support target/runtime/framework/style filters, arguments, working directory, environment variables, timeout, and required/optional failure policy. - Hook arguments and environment values support tokens such as `{projectRoot}`, `{configuration}`, `{target}`, `{rid}`, `{framework}`, `{style}`, `{bundle}`, `{phase}`, and `{hook}`. -- Hook timeout defaults to 600 seconds when omitted; set `TimeoutSeconds` lower for quick local validation hooks or higher for intentionally long-running packaging steps. +- Hook timeout defaults to 600 seconds when omitted or set to `0`; set `TimeoutSeconds` lower for quick local validation hooks or higher for intentionally long-running packaging steps. +- `BeforeBundle` and `AfterBundle` hooks run once per generated bundle step. A bundle that matches multiple target/runtime/framework/style combinations will invoke matching bundle hooks once for each produced bundle artifact. - Use `BeforeBuild` or `BeforeTargetPublish` for generated-source/catalog refreshes, and `BeforeBundle` or `AfterBundle` for product-specific package finishing that should stay repo-owned. ## MSI From Bundle diff --git a/PowerForge.Cli/Program.Helpers.RunAndParse.cs b/PowerForge.Cli/Program.Helpers.RunAndParse.cs index 6b5e750f..3b25778e 100644 --- a/PowerForge.Cli/Program.Helpers.RunAndParse.cs +++ b/PowerForge.Cli/Program.Helpers.RunAndParse.cs @@ -461,6 +461,7 @@ static void ApplyDotNetPublishSpecOverrides( var runtimes = NormalizeCliStringSet(overrideRids); if (runtimes.Length > 0) { + // Profile overrides constrain the selected matrix; target overrides drive publish expansion. if (activeProfile is not null) activeProfile.Runtimes = runtimes; @@ -474,6 +475,7 @@ static void ApplyDotNetPublishSpecOverrides( var frameworks = NormalizeCliStringSet(overrideFrameworks); if (frameworks.Length > 0) { + // Keep both layers aligned so plan/export output matches the executed publish run. if (activeProfile is not null) activeProfile.Frameworks = frameworks; @@ -490,6 +492,7 @@ static void ApplyDotNetPublishSpecOverrides( .ToArray(); if (styles.Length > 0) { + // Profile style narrows selection; target styles are what the planner expands into steps. if (activeProfile is not null) activeProfile.Style = styles.Length == 1 ? styles[0] : null; diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs index ff7c4373..37e43b23 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs @@ -160,6 +160,9 @@ public void Plan_CommandHooksUseContextSpecificKeysAndDefaultTimeout() [Fact] public void RunCommandHook_ExpandsArgumentsWorkingDirectoryAndEnvironment() { + if (!CommandExists("pwsh")) + return; + var root = CreateTempRoot(); try { @@ -238,4 +241,28 @@ private static void TryDelete(string path) // best effort } } + + private static bool CommandExists(string command) + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(path)) + return false; + + var extensions = OperatingSystem.IsWindows() + ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + : new[] { string.Empty }; + + foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + foreach (var extension in extensions) + { + var candidate = Path.Combine(directory, command + extension); + if (File.Exists(candidate)) + return true; + } + } + + return false; + } } diff --git a/Schemas/powerforge.dotnetpublish.schema.json b/Schemas/powerforge.dotnetpublish.schema.json index e2b2d23a..2c3d850f 100644 --- a/Schemas/powerforge.dotnetpublish.schema.json +++ b/Schemas/powerforge.dotnetpublish.schema.json @@ -601,8 +601,8 @@ }, "TimeoutSeconds": { "type": "integer", - "minimum": 1, - "description": "Maximum hook runtime in seconds. Defaults to 600 when omitted." + "minimum": 0, + "description": "Maximum hook runtime in seconds. Defaults to 600 when omitted or set to 0." }, "Required": { "type": "boolean" }, "Targets": { From 8b36eec7c58a91d99ce300722f81aa4df228a490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 22:44:38 +0200 Subject: [PATCH 09/16] Tighten bundle review edge cases --- ...blishPipelineRunnerBundleHardeningTests.cs | 118 +++++++++++++++++- .../DotNetPublishPipelineRunnerBundleTests.cs | 6 +- .../DotNetPublishPipelineRunnerHookTests.cs | 87 +++++++++++++ .../DotNetPublishPipelineRunner.Bundle.cs | 8 ++ .../DotNetPublishPipelineRunner.Plan.cs | 6 +- 5 files changed, 218 insertions(+), 7 deletions(-) diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs index 8b6a8006..de6cc90e 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs @@ -79,7 +79,100 @@ public void BuildBundle_CopyItemThrowsWhenDestinationExistsAndClearDestinationDi } }); - Assert.Throws(() => BuildBundle(plan, publishDir, outputDir)); + var ex = Assert.Throws(() => BuildBundle(plan, publishDir, outputDir)); + Assert.Contains("ClearDestination=false", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void BuildBundle_ModuleIncludeThrowsWhenDestinationExistsAndClearDestinationDisabled() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var moduleRoot = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Modules", "PowerTierBridge")).FullName; + File.WriteAllText(Path.Combine(moduleRoot, "PowerTierBridge.psd1"), "@{}"); + + var outputDir = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Bundles", "package")).FullName; + var existingModule = Directory.CreateDirectory(Path.Combine(outputDir, "Modules", "PowerTierBridge")).FullName; + File.WriteAllText(Path.Combine(existingModule, "PowerTierBridge.psd1"), "@{ Existing = $true }"); + + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + ClearOutput = false, + ModuleIncludes = new[] + { + new DotNetPublishBundleModuleIncludePlan + { + ModuleName = "PowerTierBridge", + SourcePath = "Artifacts/Modules/{moduleName}", + DestinationPath = "Modules/{moduleName}", + ClearDestination = false + } + } + }); + + var ex = Assert.Throws(() => BuildBundle(plan, publishDir, outputDir)); + Assert.Contains("ClearDestination=false", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Plan_ThrowsWhenBundleIncludeTargetsFormCycle() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var worker = CreateProject(root, "Worker/Worker.csproj"); + var spec = new DotNetPublishSpec + { + DotNet = new DotNetPublishDotNetOptions + { + ProjectRoot = root, + Restore = false, + Build = false, + Runtimes = new[] { "win-x64" } + }, + Targets = new[] + { + CreateTarget("App", app), + CreateTarget("Worker", worker) + }, + Bundles = new[] + { + new DotNetPublishBundle + { + Id = "app-package", + PrepareFromTarget = "App", + Includes = new[] { new DotNetPublishBundleInclude { Target = "Worker" } } + }, + new DotNetPublishBundle + { + Id = "worker-package", + PrepareFromTarget = "Worker", + Includes = new[] { new DotNetPublishBundleInclude { Target = "App" } } + } + } + }; + + var ex = Assert.Throws(() => new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null)); + Assert.Contains("dependency cycle", ex.Message, StringComparison.OrdinalIgnoreCase); } finally { @@ -137,6 +230,29 @@ private static string CreateTempRoot() return root; } + private static string CreateProject(string root, string relativePath) + { + var fullPath = Path.Combine(root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, ""); + return fullPath; + } + + private static DotNetPublishTarget CreateTarget(string name, string projectPath) + { + return new DotNetPublishTarget + { + Name = name, + ProjectPath = projectPath, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + }; + } + private static void TryDelete(string path) { try diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs index b791fb1e..2fa33c24 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs @@ -223,18 +223,20 @@ public void FindBundleSignTargets_MatchesNestedFilesAndExactPaths() var lib = Directory.CreateDirectory(Path.Combine(module, "Lib", "Core")).FullName; File.WriteAllText(Path.Combine(lib, "TierBridge.PowerShell.dll"), "dll"); + File.WriteAllText(Path.Combine(lib, "createdump.exe"), "diag"); var targets = DotNetPublishPipelineRunner.FindBundleSignTargets( bundleRoot, - new[] { "**/*.ps1", "**/*.psm1", "**/*.psd1", "**/*.dll", "App.exe" }); + new[] { "**/*.ps1", "**/*.psm1", "**/*.psd1", "**/*.dll", "App.exe", "createdump.exe" }); - Assert.Equal(6, targets.Length); + Assert.Equal(7, targets.Length); Assert.Contains(targets, path => path.EndsWith("App.exe", StringComparison.OrdinalIgnoreCase)); Assert.Contains(targets, path => path.EndsWith("RootLibrary.dll", StringComparison.OrdinalIgnoreCase)); Assert.Contains(targets, path => path.EndsWith("Install-TierBridgeService.ps1", StringComparison.OrdinalIgnoreCase)); Assert.Contains(targets, path => path.EndsWith("PowerTierBridge.psm1", StringComparison.OrdinalIgnoreCase)); Assert.Contains(targets, path => path.EndsWith("PowerTierBridge.psd1", StringComparison.OrdinalIgnoreCase)); Assert.Contains(targets, path => path.EndsWith("TierBridge.PowerShell.dll", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("createdump.exe", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(targets, path => path.EndsWith("README.md", StringComparison.OrdinalIgnoreCase)); } finally diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs index 37e43b23..b4e1c279 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs @@ -157,6 +157,65 @@ public void Plan_CommandHooksUseContextSpecificKeysAndDefaultTimeout() } } + [Fact] + public void Plan_ThrowsWhenCommandHookIdsAreDuplicated() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var spec = CreateSpec(root, app); + spec.Hooks = new[] + { + new DotNetPublishCommandHook + { + Id = "catalog", + Phase = DotNetPublishCommandHookPhase.BeforeBuild, + Command = "dotnet" + }, + new DotNetPublishCommandHook + { + Id = " catalog ", + Phase = DotNetPublishCommandHookPhase.AfterTargetPublish, + Command = "dotnet" + } + }; + + var ex = Assert.Throws(() => new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null)); + Assert.Contains("Duplicate hook ID", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Plan_ThrowsWhenCommandHookCommandIsMissing() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var spec = CreateSpec(root, app); + spec.Hooks = new[] + { + new DotNetPublishCommandHook + { + Id = "catalog", + Phase = DotNetPublishCommandHookPhase.BeforeBuild + } + }; + + var ex = Assert.Throws(() => new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null)); + Assert.Contains("Hooks['catalog'].Command", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + [Fact] public void RunCommandHook_ExpandsArgumentsWorkingDirectoryAndEnvironment() { @@ -222,6 +281,34 @@ private static string CreateProject(string root, string relativePath) return fullPath; } + private static DotNetPublishSpec CreateSpec(string root, string projectPath) + { + return new DotNetPublishSpec + { + DotNet = new DotNetPublishDotNetOptions + { + ProjectRoot = root, + Restore = false, + Build = false, + Runtimes = new[] { "win-x64" } + }, + Targets = new[] + { + new DotNetPublishTarget + { + Name = "App", + ProjectPath = projectPath, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + } + } + }; + } + private static string CreateTempRoot() { var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs index c4258b25..4211bc68 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs @@ -326,6 +326,8 @@ private void CopyBundlePath( Directory.Delete(destination, recursive: true); else if (clearDestination && File.Exists(destination)) File.Delete(destination); + else if (!clearDestination && (Directory.Exists(destination) || File.Exists(destination))) + throw new IOException($"{description}: destination already exists and ClearDestination=false: {destination}"); DirectoryCopy(source, destination); return; @@ -343,6 +345,8 @@ private void CopyBundlePath( Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); if (clearDestination && File.Exists(destinationFile)) File.Delete(destinationFile); + if (!clearDestination && File.Exists(destinationFile)) + throw new IOException($"{description}: destination already exists and ClearDestination=false: {destinationFile}"); File.Copy(source, destinationFile, overwrite: clearDestination); return; } @@ -508,6 +512,10 @@ private static bool BundleSignPatternMatches(string relativePath, string pattern if (WildcardMatch(relativePath, pattern)) return true; + var fileName = Path.GetFileName(relativePath); + if (WildcardMatch(fileName, pattern)) + return true; + if (pattern.StartsWith("**/", StringComparison.Ordinal)) return WildcardMatch(relativePath, pattern.Substring("**/".Length)); diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs index 390e2d47..0a5101dc 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs @@ -1175,7 +1175,7 @@ private static DotNetPublishCommandHook[] NormalizeCommandHooks(IEnumerable(StringComparer.OrdinalIgnoreCase), - HookTimeoutSeconds = hook.TimeoutSeconds <= 0 - ? DefaultCommandHookTimeoutSeconds - : Math.Max(1, hook.TimeoutSeconds), + HookTimeoutSeconds = hook.TimeoutSeconds, HookRequired = hook.Required, TargetName = targetName, Framework = framework, From 8dfc7541303cec3e45d87b98804049658c3219a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 22:53:50 +0200 Subject: [PATCH 10/16] Render bundle script tokens literally --- ...SPublishModule.DotNetPublish.Quickstart.md | 2 +- ...blishPipelineRunnerBundleHardeningTests.cs | 43 +++++++++++++++++++ .../DotNetPublishPipelineRunner.Hooks.cs | 1 + PowerForge/Services/ScriptTemplateRenderer.cs | 26 +++-------- Schemas/powerforge.dotnetpublish.schema.json | 2 +- 5 files changed, 53 insertions(+), 21 deletions(-) diff --git a/Docs/PSPublishModule.DotNetPublish.Quickstart.md b/Docs/PSPublishModule.DotNetPublish.Quickstart.md index 664a8dee..e0d317d2 100644 --- a/Docs/PSPublishModule.DotNetPublish.Quickstart.md +++ b/Docs/PSPublishModule.DotNetPublish.Quickstart.md @@ -128,7 +128,7 @@ Depending on config, the run can emit: - `CopyItems[]` copies non-publish files or directories such as README files, static scripts, licenses, or product data into the bundle. - `ModuleIncludes[]` copies built PowerShell module artefacts into the bundle, defaulting to `Modules/{moduleName}` so apps can ship their companion module without scraping user-profile installs. - `GeneratedScripts[]` renders inline or file-based script templates into the bundle. Template values use `{{TokenName}}`; token values can use bundle tokens such as `{output}`, `{rid}`, `{framework}`, and `{moduleName}` for module includes. -- Bundle paths and token values use single braces such as `{output}`. Generated script template bodies use double braces such as `{{ModuleName}}`; `GeneratedScripts[].Tokens` bridges the two by resolving single-brace values first and then making them available to the double-brace template renderer. +- Bundle paths and token values use single braces such as `{output}`. Generated script template bodies use double braces such as `{{ModuleName}}`; `GeneratedScripts[].Tokens` bridges the two by resolving single-brace values first and then making them available to the double-brace template renderer. Token values are inserted literally; a value containing `{{OtherToken}}` is not rendered a second time. - `GeneratedScripts[].Overwrite=false` means "fail if the target file already exists", not "skip if present". `CopyItems[].ClearDestination=false` and `ModuleIncludes[].ClearDestination=false` likewise preserve existing destinations by failing on copy conflicts. - `Scripts[]` let you run repo-specific finishing steps after the copy phase, for example exporting plugins, writing launchers, or producing metadata files. - `PostProcess.ArchiveDirectories[]` can zip bundle subdirectories after composition, which is useful for plugin packs or other nested payloads that should ship as archives. diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs index de6cc90e..9ea393cf 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs @@ -88,6 +88,49 @@ public void BuildBundle_CopyItemThrowsWhenDestinationExistsAndClearDestinationDi } } + [Fact] + public void BuildBundle_GeneratedScriptTokenValuesAreNotRenderedRecursively() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var outputDir = Path.Combine(root, "Artifacts", "Bundles", "package"); + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + GeneratedScripts = new[] + { + new DotNetPublishBundleGeneratedScriptPlan + { + Template = "{{CommandName}}", + OutputPath = "Install.ps1", + Tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CommandName"] = "{{NestedCommand}}", + ["NestedCommand"] = "Install-App" + } + } + } + }); + + BuildBundle(plan, publishDir, outputDir); + + var script = File.ReadAllText(Path.Combine(outputDir, "Install.ps1")); + Assert.Contains("{{NestedCommand}}", script, StringComparison.Ordinal); + Assert.DoesNotContain("Install-App", script, StringComparison.Ordinal); + } + finally + { + TryDelete(root); + } + } + [Fact] public void BuildBundle_ModuleIncludeThrowsWhenDestinationExistsAndClearDestinationDisabled() { diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs index 01c79888..e901c659 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs @@ -151,6 +151,7 @@ private static string TrimMatchingQuotes(string value) try { + // Bound stream draining after kill so timeout handling cannot hang on net472 pipe reads. Task.WhenAll(stdoutTask, stderrTask).Wait(TimeSpan.FromSeconds(5)); } catch diff --git a/PowerForge/Services/ScriptTemplateRenderer.cs b/PowerForge/Services/ScriptTemplateRenderer.cs index 795f62af..8819d596 100644 --- a/PowerForge/Services/ScriptTemplateRenderer.cs +++ b/PowerForge/Services/ScriptTemplateRenderer.cs @@ -16,7 +16,12 @@ internal static string Render( { var name = string.IsNullOrWhiteSpace(templateName) ? "template" : templateName.Trim(); var text = template ?? string.Empty; - var map = tokens ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in tokens ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(kv.Key)) continue; + map[kv.Key] = kv.Value ?? string.Empty; + } var referencedTokens = TokenRegex.Matches(text) .Cast() @@ -35,24 +40,7 @@ internal static string Render( $"Template '{name}' references missing token(s): {string.Join(", ", missing)}."); } - foreach (var kv in map) - { - if (string.IsNullOrWhiteSpace(kv.Key)) continue; - text = text.Replace("{{" + kv.Key + "}}", kv.Value ?? string.Empty); - } - - var unresolved = TokenRegex.Matches(text) - .Cast() - .Select(m => m.Groups.Count > 1 ? m.Groups[1].Value : string.Empty) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (unresolved.Length > 0) - { - throw new InvalidOperationException( - $"Template '{name}' contains unresolved token(s): {string.Join(", ", unresolved)}."); - } + text = TokenRegex.Replace(text, match => map[match.Groups[1].Value]); return text.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); } diff --git a/Schemas/powerforge.dotnetpublish.schema.json b/Schemas/powerforge.dotnetpublish.schema.json index 2c3d850f..1849277a 100644 --- a/Schemas/powerforge.dotnetpublish.schema.json +++ b/Schemas/powerforge.dotnetpublish.schema.json @@ -464,7 +464,7 @@ "OutputPath": { "type": "string", "minLength": 1 }, "Tokens": { "type": ["object", "null"], - "description": "Template token values. Values are expanded with single-brace bundle tokens before being exposed to double-brace placeholders in the generated script body.", + "description": "Template token values. Values are expanded with single-brace bundle tokens before being exposed to double-brace placeholders in the generated script body. Inserted values are literal and are not rendered recursively.", "additionalProperties": { "type": "string" } }, "Overwrite": { From 50725542ee0aeeab2a47048da9b0b0209b14cbdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 23:06:30 +0200 Subject: [PATCH 11/16] Clone dotnet publish specs before CLI overrides --- PowerForge.Cli/Program.Command.DotNet.cs | 2 +- PowerForge.Cli/Program.Helpers.RunAndParse.cs | 11 ++++++++++- PowerForge.Tests/RepoRootLocator.cs | 4 +++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/PowerForge.Cli/Program.Command.DotNet.cs b/PowerForge.Cli/Program.Command.DotNet.cs index 17f9ec74..a3740de7 100644 --- a/PowerForge.Cli/Program.Command.DotNet.cs +++ b/PowerForge.Cli/Program.Command.DotNet.cs @@ -82,7 +82,7 @@ private static int CommandDotNet(string[] filteredArgs, CliOptions cli, ILogger : CreateCommandLogger(outputJson, cli, logger); var loaded = LoadDotNetPublishSpecWithPath(configPath); - var spec = loaded.Value; + var spec = CloneDotNetPublishSpec(loaded.Value); var specPath = loaded.FullPath; var matrixOverrides = ParseDotNetPublishMatrixOverrides(subArgs); var effectiveRids = overrideRids.Length > 0 ? overrideRids : matrixOverrides.Runtimes; diff --git a/PowerForge.Cli/Program.Helpers.RunAndParse.cs b/PowerForge.Cli/Program.Helpers.RunAndParse.cs index 3b25778e..7010e913 100644 --- a/PowerForge.Cli/Program.Helpers.RunAndParse.cs +++ b/PowerForge.Cli/Program.Helpers.RunAndParse.cs @@ -422,6 +422,15 @@ static DotNetPublishStyle ParseDotNetPublishStyle(string? value) throw new ArgumentException($"Unknown style: {raw}. Expected one of: {string.Join(", ", Enum.GetNames(typeof(DotNetPublishStyle)))}", nameof(value)); } + static DotNetPublishSpec CloneDotNetPublishSpec(DotNetPublishSpec spec) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var json = JsonSerializer.Serialize(spec, CliJson.Context.DotNetPublishSpec); + return JsonSerializer.Deserialize(json, CliJson.Context.DotNetPublishSpec) + ?? throw new InvalidOperationException("Failed to clone dotnet publish spec."); + } + static void ApplyDotNetPublishSpecOverrides( DotNetPublishSpec spec, string[] overrideTargets, @@ -492,7 +501,7 @@ static void ApplyDotNetPublishSpecOverrides( .ToArray(); if (styles.Length > 0) { - // Profile style narrows selection; target styles are what the planner expands into steps. + // Profile style is single-valued; multiple CLI styles are represented on targets for planner expansion. if (activeProfile is not null) activeProfile.Style = styles.Length == 1 ? styles[0] : null; diff --git a/PowerForge.Tests/RepoRootLocator.cs b/PowerForge.Tests/RepoRootLocator.cs index c27fc7ef..2ec3e9b7 100644 --- a/PowerForge.Tests/RepoRootLocator.cs +++ b/PowerForge.Tests/RepoRootLocator.cs @@ -2,10 +2,12 @@ namespace PowerForge.Tests; internal static class RepoRootLocator { + private const int MaxSearchDepth = 12; + public static string Find() { var current = new DirectoryInfo(AppContext.BaseDirectory); - for (var i = 0; i < 12 && current is not null; i++) + for (var i = 0; i < MaxSearchDepth && current is not null; i++) { var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); if (File.Exists(marker)) From e046ae46bb49aa40d755d093ff79b00a34baa494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 23:13:24 +0200 Subject: [PATCH 12/16] Tighten bundle script and hook defaults --- PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs | 2 +- PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs | 5 ++++- PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs | 4 +++- PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs b/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs index e7fd8c2e..9e39fc1f 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs @@ -534,7 +534,7 @@ public sealed class DotNetPublishStep public Dictionary HookEnvironment { get; set; } = new(StringComparer.OrdinalIgnoreCase); /// Optional command hook timeout in seconds. - public int HookTimeoutSeconds { get; set; } = 600; + public int HookTimeoutSeconds { get; set; } = DotNetPublishCommandHook.DefaultTimeoutSeconds; /// When true, command hook failures fail the publish run. public bool HookRequired { get; set; } = true; diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs index 0ced047b..f111cd0f 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs @@ -796,6 +796,9 @@ public sealed class DotNetPublishBenchmarkMetric /// public sealed class DotNetPublishCommandHook { + /// Default command hook timeout in seconds. + public const int DefaultTimeoutSeconds = 600; + /// Stable hook identifier used in plan step keys. public string Id { get; set; } = string.Empty; @@ -815,7 +818,7 @@ public sealed class DotNetPublishCommandHook public Dictionary? Environment { get; set; } /// Maximum command execution time in seconds. Default: 600. - public int TimeoutSeconds { get; set; } = 600; + public int TimeoutSeconds { get; set; } = DefaultTimeoutSeconds; /// When true, a non-zero exit code fails the publish run. Default: true. public bool Required { get; set; } = true; diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs index 4211bc68..f395d476 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs @@ -301,7 +301,9 @@ private void GenerateBundleScripts( renderTokens[token.Key] = ApplyTemplate(token.Value ?? string.Empty, tokens); var rendered = ScriptTemplateRenderer.Render(templateName ?? "bundle generated script", template ?? string.Empty, renderTokens); - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + var outputDirectory = Path.GetDirectoryName(outputPath) + ?? throw new InvalidOperationException($"Cannot determine parent directory for generated script output: {outputPath}"); + Directory.CreateDirectory(outputDirectory); File.WriteAllText(outputPath, rendered, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); _logger.Info($"Generated bundle script -> {FrameworkCompatibility.GetRelativePath(outputDir, outputPath)} ({bundle.Id})"); diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs index 0a5101dc..0fab30ca 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs @@ -8,7 +8,7 @@ namespace PowerForge; public sealed partial class DotNetPublishPipelineRunner { - private const int DefaultCommandHookTimeoutSeconds = 600; + private const int DefaultCommandHookTimeoutSeconds = DotNetPublishCommandHook.DefaultTimeoutSeconds; private const string DefaultMsiPrepareStagingPathTemplate = "Artifacts/DotNetPublish/Msi/{installer}/{target}/{rid}/{framework}/{style}/payload"; From 3530c8bfc10ca62770dd7f12f96e9a43c47fe48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 23:19:32 +0200 Subject: [PATCH 13/16] Polish hook diagnostics and changelog --- CHANGELOG.MD | 3 ++ PowerForge.Cli/Program.Helpers.RunAndParse.cs | 1 + .../DotNetPublishPipelineRunnerHookTests.cs | 38 +++++++++++++++++++ .../DotNetPublishPipelineRunner.Bundle.cs | 1 + .../DotNetPublishPipelineRunner.Hooks.cs | 11 ++++-- PowerForge/Services/ScriptTemplateRenderer.cs | 9 ++++- 6 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 1198ca11..2b7e4125 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,6 +1,9 @@ # PSPublishModule Changelog ## Unreleased +- Add reusable dotnet publish command hooks (`Hooks[]`) for restore, build, target publish, and bundle phases. +- Add bundle composition primitives for primary subdirectories, copy items, module includes, and generated scripts. +- Add bundle post-process signing and MSI harvest exclude patterns for package/MSI reuse across projects. - DotNet publish CLI overrides (`--target`, `--runtime`, `--framework`, `--style`) are applied before profile resolution so profile-filtered plan/export output matches the executed run. ## 2.0.19 - 2025.06.17 diff --git a/PowerForge.Cli/Program.Helpers.RunAndParse.cs b/PowerForge.Cli/Program.Helpers.RunAndParse.cs index 7010e913..ab36264e 100644 --- a/PowerForge.Cli/Program.Helpers.RunAndParse.cs +++ b/PowerForge.Cli/Program.Helpers.RunAndParse.cs @@ -491,6 +491,7 @@ static void ApplyDotNetPublishSpecOverrides( foreach (var target in targets) { if (target.Publish is null) continue; + // Singular Framework is kept for compatibility; Frameworks drives matrix expansion. target.Publish.Framework = frameworks[0]; target.Publish.Frameworks = frameworks; } diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs index b4e1c279..c11d0bb2 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs @@ -273,6 +273,44 @@ public void RunCommandHook_ExpandsArgumentsWorkingDirectoryAndEnvironment() } } + [Fact] + public void RunCommandHook_ReportsTimeoutExplicitly() + { + if (!CommandExists("pwsh")) + return; + + var root = CreateTempRoot(); + try + { + var step = new DotNetPublishStep + { + Key = "hook:BeforeBuild:slow", + Kind = DotNetPublishStepKind.CommandHook, + HookId = "slow", + HookPhase = DotNetPublishCommandHookPhase.BeforeBuild, + HookCommand = "pwsh", + HookArguments = new[] { "-NoLogo", "-NoProfile", "-Command", "Start-Sleep -Seconds 10" }, + HookTimeoutSeconds = 1, + HookRequired = true + }; + + var ex = Assert.ThrowsAny(() => + new DotNetPublishPipelineRunner(new NullLogger()).RunCommandHook( + new DotNetPublishPlan + { + ProjectRoot = root, + Configuration = "Release" + }, + step)); + + Assert.Contains("timed out after 1 seconds", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + private static string CreateProject(string root, string relativePath) { var fullPath = Path.Combine(root, relativePath); diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs index f395d476..2cfc5b6b 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs @@ -511,6 +511,7 @@ internal static string[] FindBundleSignTargets(string bundleRoot, IReadOnlyList< private static bool BundleSignPatternMatches(string relativePath, string pattern) { + // Match full relative paths, bare basename globs, and rootless **/ globs used by bundle configs. if (WildcardMatch(relativePath, pattern)) return true; diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs index e901c659..bfa9cb15 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs @@ -60,7 +60,9 @@ internal void RunCommandHook(DotNetPublishPlan plan, DotNetPublishStep step) var stderrTail = TailLines(result.StdErr, maxLines: 40, maxChars: 4000); var stdoutTail = TailLines(result.StdOut, maxLines: 40, maxChars: 4000); - var message = ExtractLastNonEmptyLine(!string.IsNullOrWhiteSpace(stderrTail) ? stderrTail : stdoutTail); + var message = result.TimedOut + ? $"Hook '{step.HookId}' timed out after {Math.Max(1, step.HookTimeoutSeconds)} seconds." + : ExtractLastNonEmptyLine(!string.IsNullOrWhiteSpace(stderrTail) ? stderrTail : stdoutTail); if (string.IsNullOrWhiteSpace(message)) message = $"Hook '{step.HookId}' failed with exit code {result.ExitCode}."; @@ -100,7 +102,7 @@ private static string TrimMatchingQuotes(string value) return value; } - private (int ExitCode, string StdOut, string StdErr, string Executable) RunHookProcess( + private (int ExitCode, string StdOut, string StdErr, string Executable, bool TimedOut) RunHookProcess( string fileName, string workingDirectory, IReadOnlyList args, @@ -161,14 +163,15 @@ private static string TrimMatchingQuotes(string value) var stdout = TryGetCompletedOutput(stdoutTask); var stderr = TryGetCompletedOutput(stderrTask); - return (-1, stdout, stderr, fileName); + return (-1, stdout, stderr, fileName, TimedOut: true); } return ( process.ExitCode, stdoutTask.GetAwaiter().GetResult(), stderrTask.GetAwaiter().GetResult(), - fileName); + fileName, + TimedOut: false); } private static string TryGetCompletedOutput(Task task) diff --git a/PowerForge/Services/ScriptTemplateRenderer.cs b/PowerForge/Services/ScriptTemplateRenderer.cs index 8819d596..dc343bdf 100644 --- a/PowerForge/Services/ScriptTemplateRenderer.cs +++ b/PowerForge/Services/ScriptTemplateRenderer.cs @@ -40,7 +40,14 @@ internal static string Render( $"Template '{name}' references missing token(s): {string.Join(", ", missing)}."); } - text = TokenRegex.Replace(text, match => map[match.Groups[1].Value]); + text = TokenRegex.Replace(text, match => + { + var token = match.Groups[1].Value; + if (!map.TryGetValue(token, out var value)) + throw new InvalidOperationException($"Template '{name}' references missing token(s): {token}."); + + return value; + }); return text.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); } From 1ff34bc7ce49f792fe01f8cce49664d9763b63cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 23:26:17 +0200 Subject: [PATCH 14/16] Warn on multi-style profile overrides --- PowerForge.Cli/Program.Command.DotNet.cs | 2 ++ PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/PowerForge.Cli/Program.Command.DotNet.cs b/PowerForge.Cli/Program.Command.DotNet.cs index a3740de7..74136685 100644 --- a/PowerForge.Cli/Program.Command.DotNet.cs +++ b/PowerForge.Cli/Program.Command.DotNet.cs @@ -94,6 +94,8 @@ private static int CommandDotNet(string[] filteredArgs, CliOptions cli, ILogger var runner = new DotNetPublishPipelineRunner(cmdLogger); if (!string.IsNullOrWhiteSpace(overrideProfile)) spec.Profile = overrideProfile.Trim(); + if (effectiveStyles.Length > 1 && GetActiveDotNetPublishProfile(spec) is not null) + cmdLogger.Warn("Multiple --style values were provided with an active profile; the single-value profile style filter is ignored and target styles drive matrix expansion."); ApplyDotNetPublishSpecOverrides(spec, overrideTargets, effectiveRids, effectiveFrameworks, effectiveStyles); var plan = runner.Plan(spec, specPath); ApplyDotNetPublishSkipFlags(plan, skipRestore, skipBuild); diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs index c11d0bb2..96544a25 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs @@ -289,8 +289,8 @@ public void RunCommandHook_ReportsTimeoutExplicitly() HookId = "slow", HookPhase = DotNetPublishCommandHookPhase.BeforeBuild, HookCommand = "pwsh", - HookArguments = new[] { "-NoLogo", "-NoProfile", "-Command", "Start-Sleep -Seconds 10" }, - HookTimeoutSeconds = 1, + HookArguments = new[] { "-NoLogo", "-NoProfile", "-Command", "[System.Threading.Thread]::Sleep([int]::MaxValue)" }, + HookTimeoutSeconds = 2, HookRequired = true }; @@ -303,7 +303,7 @@ public void RunCommandHook_ReportsTimeoutExplicitly() }, step)); - Assert.Contains("timed out after 1 seconds", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("timed out after 2 seconds", ex.Message, StringComparison.OrdinalIgnoreCase); } finally { From 3d71825bdbc3c890c4134cad16180ea92e3ad1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 23:34:20 +0200 Subject: [PATCH 15/16] Harden bundle copy and hook stream handling --- ...blishPipelineRunnerBundleHardeningTests.cs | 75 +++++++++++++++++++ .../DotNetPublishPipelineRunner.Bundle.cs | 15 ++-- .../DotNetPublishPipelineRunner.Hooks.cs | 1 + 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs index 9ea393cf..8e54e345 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs @@ -88,6 +88,53 @@ public void BuildBundle_CopyItemThrowsWhenDestinationExistsAndClearDestinationDi } } + [Fact] + public void BuildBundle_CopyItemFileIntoExistingDirectoryKeepsFileNameWhenClearing() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var sourcePath = Path.Combine(root, "Build", "tool.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + File.WriteAllText(sourcePath, "tool"); + + var outputDir = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Bundles", "package")).FullName; + var existingDirectory = Directory.CreateDirectory(Path.Combine(outputDir, "Lib")).FullName; + File.WriteAllText(Path.Combine(existingDirectory, "old.txt"), "old"); + + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + ClearOutput = false, + CopyItems = new[] + { + new DotNetPublishBundleCopyItemPlan + { + SourcePath = "Build/tool.dll", + DestinationPath = "Lib", + ClearDestination = true + } + } + }); + + BuildBundle(plan, publishDir, outputDir); + + Assert.True(Directory.Exists(Path.Combine(outputDir, "Lib"))); + Assert.True(File.Exists(Path.Combine(outputDir, "Lib", "tool.dll"))); + Assert.False(File.Exists(Path.Combine(outputDir, "Lib", "old.txt"))); + } + finally + { + TryDelete(root); + } + } + [Fact] public void BuildBundle_GeneratedScriptTokenValuesAreNotRenderedRecursively() { @@ -175,6 +222,34 @@ public void BuildBundle_ModuleIncludeThrowsWhenDestinationExistsAndClearDestinat } } + [Fact] + public void FindBundleSignTargets_MatchesFolderGlobAtRootAndNestedPaths() + { + var root = CreateTempRoot(); + try + { + var rootModule = Path.Combine(root, "Modules", "Root.dll"); + var nestedModule = Path.Combine(root, "Lib", "Modules", "Nested.dll"); + var other = Path.Combine(root, "Lib", "Other", "Other.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(rootModule)!); + Directory.CreateDirectory(Path.GetDirectoryName(nestedModule)!); + Directory.CreateDirectory(Path.GetDirectoryName(other)!); + File.WriteAllText(rootModule, "root"); + File.WriteAllText(nestedModule, "nested"); + File.WriteAllText(other, "other"); + + var targets = DotNetPublishPipelineRunner.FindBundleSignTargets(root, new[] { "**/Modules/*.dll" }); + + Assert.Contains(rootModule, targets); + Assert.Contains(nestedModule, targets); + Assert.DoesNotContain(other, targets); + } + finally + { + TryDelete(root); + } + } + [Fact] public void Plan_ThrowsWhenBundleIncludeTargetsFormCycle() { diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs index 2cfc5b6b..af47f532 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs @@ -337,14 +337,17 @@ private void CopyBundlePath( if (File.Exists(source)) { - if (clearDestination && Directory.Exists(destination)) - Directory.Delete(destination, recursive: true); - - var destinationFile = destination; - if (Directory.Exists(destinationFile)) - destinationFile = Path.Combine(destinationFile, Path.GetFileName(source)); + var destinationWasDirectory = Directory.Exists(destination); + var destinationFile = destinationWasDirectory + ? Path.Combine(destination, Path.GetFileName(source)) + : destination; Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + if (clearDestination && destinationWasDirectory) + { + Directory.Delete(destination, recursive: true); + Directory.CreateDirectory(destination); + } if (clearDestination && File.Exists(destinationFile)) File.Delete(destinationFile); if (!clearDestination && File.Exists(destinationFile)) diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs index bfa9cb15..3e19ad10 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs @@ -166,6 +166,7 @@ private static string TrimMatchingQuotes(string value) return (-1, stdout, stderr, fileName, TimedOut: true); } + process.WaitForExit(); return ( process.ExitCode, stdoutTask.GetAwaiter().GetResult(), From 57d57f59fa7f8deda505a9203fda45e12898f507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 28 Apr 2026 23:39:19 +0200 Subject: [PATCH 16/16] Bound hook stream drain on net472 --- PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs index 3e19ad10..99b4564d 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs @@ -166,7 +166,11 @@ private static string TrimMatchingQuotes(string value) return (-1, stdout, stderr, fileName, TimedOut: true); } +#if NET472 + process.WaitForExit(30_000); +#else process.WaitForExit(); +#endif return ( process.ExitCode, stdoutTask.GetAwaiter().GetResult(),