diff --git a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 index 707b2e69..5a6ace24 100644 --- a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 +++ b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 @@ -122,8 +122,8 @@ if ($env:INPUT_APPLY -eq 'true') { } if (-not [string]::IsNullOrWhiteSpace($env:POWERFORGE_GITHUB_TOKEN)) { - $null = $arguments.Add('--token') - $null = $arguments.Add($env:POWERFORGE_GITHUB_TOKEN) + $null = $arguments.Add('--token-env') + $null = $arguments.Add('POWERFORGE_GITHUB_TOKEN') } $null = $arguments.Add('--output') diff --git a/Build/Build-PowerForge.ps1 b/Build/Build-PowerForge.ps1 index f44b096f..ac370df3 100644 --- a/Build/Build-PowerForge.ps1 +++ b/Build/Build-PowerForge.ps1 @@ -3,413 +3,35 @@ [string[]] $Tool = @('PowerForge'), [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'osx-x64', 'osx-arm64')] [string[]] $Runtime = @('win-x64'), - [ValidateSet('Debug', 'Release')] - [string] $Configuration = 'Release', [ValidateSet('net10.0', 'net8.0')] [string] $Framework = 'net10.0', [ValidateSet('SingleContained', 'SingleFx', 'Portable', 'Fx')] [string] $Flavor = 'SingleContained', - [string] $OutDir, - [switch] $ClearOut, - [switch] $Zip, - [switch] $UseStaging = $true, - [switch] $KeepSymbols, - [switch] $KeepDocs, + [switch] $Plan, + [switch] $Validate, [switch] $PublishGitHub, - [string] $GitHubUsername = 'EvotecIT', - [string] $GitHubRepositoryName = 'PSPublishModule', - [string] $GitHubAccessToken, - [string] $GitHubAccessTokenFilePath, - [string] $GitHubAccessTokenEnvName = 'GITHUB_TOKEN', - [string] $GitHubTagName, - [string] $GitHubReleaseName, - [switch] $GenerateReleaseNotes = $true, - [switch] $IsPreRelease + [string] $ConfigPath ) -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -function Write-Header($Text) { Write-Host "`n=== $Text ===" -ForegroundColor Cyan } -function Write-Step($Text) { Write-Host "-> $Text" -ForegroundColor Yellow } -function Write-Ok($Text) { Write-Host "[ok] $Text" -ForegroundColor Green } - -$repoRoot = (Resolve-Path -LiteralPath ([IO.Path]::GetFullPath([IO.Path]::Combine($PSScriptRoot, '..')))).Path -$moduleManifest = Join-Path $repoRoot 'PSPublishModule\PSPublishModule.psd1' -$outDirProvided = $PSBoundParameters.ContainsKey('OutDir') -and -not [string]::IsNullOrWhiteSpace($OutDir) - -if ($PublishGitHub) { - $Zip = $true -} - -$toolDefinitions = @{ - PowerForge = @{ - ProjectPath = Join-Path $repoRoot 'PowerForge.Cli\PowerForge.Cli.csproj' - ArtifactRoot = Join-Path $repoRoot 'Artifacts\PowerForge' - OutputName = 'PowerForge' - OutputNameLower = 'powerforge' - PublishedBinaryCandidates = @('PowerForge.Cli.exe', 'PowerForge.Cli') - } - PowerForgeWeb = @{ - ProjectPath = Join-Path $repoRoot 'PowerForge.Web.Cli\PowerForge.Web.Cli.csproj' - ArtifactRoot = Join-Path $repoRoot 'Artifacts\PowerForgeWeb' - OutputName = 'PowerForgeWeb' - OutputNameLower = 'powerforge-web' - PublishedBinaryCandidates = @('PowerForge.Web.Cli.exe', 'PowerForge.Web.Cli') - } -} - -function Resolve-ToolSelection { - param([string[]] $SelectedTools) - - $normalized = @( - @($SelectedTools) | - Where-Object { $_ -and $_.Trim() } | - ForEach-Object { $_.Trim() } | - Select-Object -Unique - ) - - if ($normalized.Count -eq 0) { - throw 'Tool selection cannot be empty.' - } - - if ($normalized -contains 'All') { - return @('PowerForge', 'PowerForgeWeb') - } - - return $normalized -} - -function Resolve-ProjectVersion { - param([Parameter(Mandatory)][string] $ProjectPath) - - [xml] $xml = Get-Content -LiteralPath $ProjectPath -Raw - $node = $xml.SelectSingleNode("/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='Version']") - if (-not $node) { - $node = $xml.SelectSingleNode("/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='VersionPrefix']") - } - - if (-not $node -or [string]::IsNullOrWhiteSpace($node.InnerText)) { - throw "Unable to resolve Version/VersionPrefix from $ProjectPath" - } - - return $node.InnerText.Trim() -} - -function Resolve-OutDir { - param( - [Parameter(Mandatory)][string] $ToolName, - [Parameter(Mandatory)][string] $Rid, - [Parameter(Mandatory)][string] $DefaultRoot, - [Parameter(Mandatory)][bool] $MultiTool, - [Parameter(Mandatory)][bool] $MultiRuntime - ) - - if ($outDirProvided) { - $root = $OutDir - if ($MultiTool) { - $root = Join-Path $root $ToolName - } - if ($MultiRuntime) { - return Join-Path $root ("{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) - } - return $root - } - - return Join-Path $DefaultRoot ("{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) -} - -function Resolve-ToolOutputRoot { - param( - [Parameter(Mandatory)][string] $ToolName, - [Parameter(Mandatory)][string] $DefaultRoot, - [Parameter(Mandatory)][bool] $MultiTool - ) - - if ($outDirProvided) { - if ($MultiTool) { - return Join-Path $OutDir $ToolName - } - - return $OutDir - } - - return $DefaultRoot -} - -function Remove-DirectoryContents { - param([Parameter(Mandatory)][string] $Path) - - if (-not (Test-Path -LiteralPath $Path)) { - return - } - - Get-ChildItem -Path $Path -Force -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -} - -function Resolve-GitHubToken { - if (-not $PublishGitHub) { - return $null - } - - if (-not [string]::IsNullOrWhiteSpace($GitHubAccessToken)) { - return $GitHubAccessToken.Trim() - } - - if (-not [string]::IsNullOrWhiteSpace($GitHubAccessTokenFilePath)) { - $tokenPath = if ([IO.Path]::IsPathRooted($GitHubAccessTokenFilePath)) { - $GitHubAccessTokenFilePath - } else { - Join-Path $repoRoot $GitHubAccessTokenFilePath - } - - if (Test-Path -LiteralPath $tokenPath) { - return (Get-Content -LiteralPath $tokenPath -Raw).Trim() - } - } - - if (-not [string]::IsNullOrWhiteSpace($GitHubAccessTokenEnvName)) { - $envToken = [Environment]::GetEnvironmentVariable($GitHubAccessTokenEnvName) - if (-not [string]::IsNullOrWhiteSpace($envToken)) { - return $envToken.Trim() - } - } - - throw 'GitHub token is required when -PublishGitHub is used.' +if (-not $PSBoundParameters.ContainsKey('ConfigPath') -or [string]::IsNullOrWhiteSpace($ConfigPath)) { + $ConfigPath = Join-Path $PSScriptRoot 'release.json' } -function Publish-GitHubAssets { - param( - [Parameter(Mandatory)][string] $ToolName, - [Parameter(Mandatory)][string] $Version, - [Parameter(Mandatory)][string[]] $AssetPaths, - [Parameter(Mandatory)][string] $Token - ) - - if ($AssetPaths.Count -eq 0) { - throw "No assets were created for $ToolName, so there is nothing to publish." - } - - Import-Module $moduleManifest -Force -ErrorAction Stop - - $tagName = if (-not [string]::IsNullOrWhiteSpace($GitHubTagName) -and $selectedTools.Count -eq 1) { - $GitHubTagName.Trim() - } else { - "$ToolName-v$Version" - } - - $releaseName = if (-not [string]::IsNullOrWhiteSpace($GitHubReleaseName) -and $selectedTools.Count -eq 1) { - $GitHubReleaseName.Trim() - } else { - "$ToolName $Version" - } - - Write-Step "Publishing $ToolName assets to GitHub release $tagName" - $publishResult = Send-GitHubRelease ` - -GitHubUsername $GitHubUsername ` - -GitHubRepositoryName $GitHubRepositoryName ` - -GitHubAccessToken $Token ` - -TagName $tagName ` - -ReleaseName $releaseName ` - -GenerateReleaseNotes:$GenerateReleaseNotes ` - -IsPreRelease:$IsPreRelease ` - -AssetFilePaths $AssetPaths - - if (-not $publishResult.Succeeded) { - throw "GitHub release publish failed for ${ToolName}: $($publishResult.ErrorMessage)" - } - - Write-Ok "$ToolName release published -> $($publishResult.ReleaseUrl)" +if ($Tool -contains 'All') { + $Tool = @('PowerForge', 'PowerForgeWeb') } -$selectedTools = @(Resolve-ToolSelection -SelectedTools $Tool) -$multiTool = $selectedTools.Count -gt 1 -$rids = @( - @($Runtime) | - Where-Object { $_ -and $_.Trim() } | - ForEach-Object { $_.Trim() } | - Select-Object -Unique -) -if ($rids.Count -eq 0) { - throw 'Runtime must not be empty.' +$buildProjectParams = @{ + ConfigPath = $ConfigPath + ToolsOnly = $true + Target = $Tool + Runtime = $Runtime + Framework = @($Framework) + Flavor = @($Flavor) } -$multiRuntime = $rids.Count -gt 1 -$singleFile = $Flavor -in @('SingleContained', 'SingleFx') -$selfContained = $Flavor -in @('SingleContained', 'Portable') -$compress = $singleFile -$selfExtract = $Flavor -eq 'SingleContained' -$gitHubToken = Resolve-GitHubToken -$publishedAssets = @{} - -Write-Header "Build tools ($Flavor)" -Write-Step "Framework -> $Framework" -Write-Step "Configuration -> $Configuration" -Write-Step ("Tools -> {0}" -f ($selectedTools -join ', ')) -Write-Step ("Runtimes -> {0}" -f ($rids -join ', ')) - -foreach ($toolName in $selectedTools) { - $definition = $toolDefinitions[$toolName] - if (-not $definition) { - throw "Unsupported tool: $toolName" - } - - $projectPath = [string] $definition.ProjectPath - if (-not (Test-Path -LiteralPath $projectPath)) { - throw "Project not found: $projectPath" - } - - $artifactRoot = [string] $definition.ArtifactRoot - $toolOutputRoot = Resolve-ToolOutputRoot -ToolName $toolName -DefaultRoot $artifactRoot -MultiTool:$multiTool - $outputName = [string] $definition.OutputName - $lowerAlias = [string] $definition.OutputNameLower - $candidateNames = @($definition.PublishedBinaryCandidates) - $version = Resolve-ProjectVersion -ProjectPath $projectPath - $publishedAssets[$toolName] = @() - - foreach ($rid in $rids) { - $outDirThis = Resolve-OutDir -ToolName $toolName -Rid $rid -DefaultRoot $artifactRoot -MultiTool:$multiTool -MultiRuntime:$multiRuntime - New-Item -ItemType Directory -Force -Path $outDirThis | Out-Null - - $publishDir = $outDirThis - $stagingDir = $null - if ($UseStaging) { - $stagingDir = Join-Path $env:TEMP ($outputName + '.publish.' + [guid]::NewGuid().ToString('N')) - $publishDir = $stagingDir - Write-Step "Using staging publish dir -> $publishDir" - if (Test-Path -LiteralPath $publishDir) { - Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue - } - New-Item -ItemType Directory -Force -Path $publishDir | Out-Null - } - - Write-Step "$toolName runtime -> $rid" - Write-Step "Publishing -> $publishDir" - - $publishArgs = @( - 'publish', $projectPath, - '-c', $Configuration, - '-f', $Framework, - '-r', $rid, - "--self-contained:$selfContained", - "/p:PublishSingleFile=$singleFile", - "/p:PublishReadyToRun=false", - "/p:PublishTrimmed=false", - "/p:IncludeAllContentForSelfExtract=$selfExtract", - "/p:IncludeNativeLibrariesForSelfExtract=$selfExtract", - "/p:EnableCompressionInSingleFile=$compress", - "/p:EnableSingleFileAnalyzer=false", - "/p:DebugType=None", - "/p:DebugSymbols=false", - "/p:GenerateDocumentationFile=false", - "/p:CopyDocumentationFiles=false", - "/p:ExcludeSymbolsFromSingleFile=true", - "/p:ErrorOnDuplicatePublishOutputFiles=false", - "/p:UseAppHost=true", - "/p:PublishDir=$publishDir" - ) - - if ($ClearOut -and (Test-Path -LiteralPath $outDirThis) -and ($publishDir -eq $outDirThis)) { - Write-Step "Clearing $outDirThis" - Remove-DirectoryContents -Path $outDirThis - } +if ($Plan) { $buildProjectParams.Plan = $true } +if ($Validate) { $buildProjectParams.Validate = $true } +if ($PublishGitHub) { $buildProjectParams.PublishToolGitHub = $true } - dotnet @publishArgs - if ($LASTEXITCODE -ne 0) { - throw "Publish failed for $toolName ($LASTEXITCODE)" - } - - if (-not $KeepSymbols) { - Write-Step "Removing symbols (*.pdb)" - Get-ChildItem -Path $publishDir -Filter *.pdb -File -Recurse -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - } - - if (-not $KeepDocs) { - Write-Step "Removing docs (*.xml, *.pdf)" - Get-ChildItem -Path $publishDir -File -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.Extension -in @('.xml', '.pdf') } | - Remove-Item -Force -ErrorAction SilentlyContinue - } - - $ridIsWindows = $rid -like 'win-*' - $friendlyBinaryName = if ($ridIsWindows) { $outputName + '.exe' } else { $outputName } - $friendlyBinary = Join-Path $publishDir $friendlyBinaryName - foreach ($candidateName in $candidateNames) { - $candidatePath = Join-Path $publishDir $candidateName - if (-not (Test-Path -LiteralPath $candidatePath)) { - continue - } - - if (Test-Path -LiteralPath $friendlyBinary) { - Remove-Item -LiteralPath $friendlyBinary -Force -ErrorAction SilentlyContinue - } - - Move-Item -LiteralPath $candidatePath -Destination $friendlyBinary -Force - break - } - - if (-not (Test-Path -LiteralPath $friendlyBinary)) { - throw "Friendly output binary was not created for $toolName ($rid): $friendlyBinary" - } - - if ($ClearOut -and (Test-Path -LiteralPath $outDirThis) -and ($publishDir -ne $outDirThis)) { - Write-Step "Clearing $outDirThis" - Remove-DirectoryContents -Path $outDirThis - } - - if ($publishDir -ne $outDirThis) { - Write-Step "Copying publish output -> $outDirThis" - Copy-Item -Path (Join-Path $publishDir '*') -Destination $outDirThis -Recurse -Force - } - - if ($Zip) { - $zipName = "{0}-{1}-{2}-{3}-{4}.zip" -f $outputName, $version, $Framework, $rid, $Flavor - $zipPath = Join-Path (Split-Path -Parent $outDirThis) $zipName - if (Test-Path -LiteralPath $zipPath) { - Remove-Item -LiteralPath $zipPath -Force - } - Write-Step "Create zip -> $zipPath" - Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::CreateFromDirectory($outDirThis, $zipPath) - $publishedAssets[$toolName] += $zipPath - } - - if ($rid -notlike 'win-*') { - $lowerAliasPath = Join-Path $outDirThis $lowerAlias - $friendlyPublishedBinary = Join-Path $outDirThis $outputName - if ((Test-Path -LiteralPath $friendlyPublishedBinary) -and -not (Test-Path -LiteralPath $lowerAliasPath)) { - Copy-Item -LiteralPath $friendlyPublishedBinary -Destination $lowerAliasPath -Force - } - } - - if ($stagingDir -and (Test-Path -LiteralPath $stagingDir)) { - Remove-Item -Path $stagingDir -Recurse -Force -ErrorAction SilentlyContinue - } - } - - if (-not (Test-Path -LiteralPath $toolOutputRoot)) { - New-Item -ItemType Directory -Force -Path $toolOutputRoot | Out-Null - } - - $manifestPath = Join-Path $toolOutputRoot 'release-manifest.json' - $manifest = [ordered]@{ - Tool = $toolName - Version = $version - Framework = $Framework - Flavor = $Flavor - Runtimes = $rids - Assets = @($publishedAssets[$toolName]) - } | ConvertTo-Json -Depth 5 - Set-Content -LiteralPath $manifestPath -Value $manifest - Write-Ok "$toolName artifacts -> $toolOutputRoot" - - if ($PublishGitHub) { - Publish-GitHubAssets -ToolName $toolName -Version $version -AssetPaths @($publishedAssets[$toolName]) -Token $gitHubToken - } -} - -if ($multiTool) { - $root = if ($outDirProvided) { $OutDir } else { Join-Path $repoRoot 'Artifacts' } - Write-Ok "Built tool artifacts -> $root" -} +& (Join-Path $PSScriptRoot 'Build-Project.ps1') @buildProjectParams +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/Build/Build-PowerForgeWeb.ps1 b/Build/Build-PowerForgeWeb.ps1 index 1923b221..8a1190de 100644 --- a/Build/Build-PowerForgeWeb.ps1 +++ b/Build/Build-PowerForgeWeb.ps1 @@ -1,60 +1,30 @@ [CmdletBinding()] param( [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'osx-x64', 'osx-arm64')] [string[]] $Runtime = @('win-x64'), - [ValidateSet('Debug', 'Release')] - [string] $Configuration = 'Release', [ValidateSet('net10.0', 'net8.0')] [string] $Framework = 'net10.0', [ValidateSet('SingleContained', 'SingleFx', 'Portable', 'Fx')] [string] $Flavor = 'SingleContained', - [string] $OutDir, - [switch] $ClearOut, - [switch] $Zip, - [switch] $UseStaging = $true, - [switch] $KeepSymbols, - [switch] $KeepDocs, + [switch] $Plan, + [switch] $Validate, [switch] $PublishGitHub, - [string] $GitHubUsername = 'EvotecIT', - [string] $GitHubRepositoryName = 'PSPublishModule', - [string] $GitHubAccessToken, - [string] $GitHubAccessTokenFilePath, - [string] $GitHubAccessTokenEnvName = 'GITHUB_TOKEN', - [string] $GitHubTagName, - [string] $GitHubReleaseName, - [switch] $GenerateReleaseNotes = $true, - [switch] $IsPreRelease + [string] $ConfigPath ) -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' +if (-not $PSBoundParameters.ContainsKey('ConfigPath') -or [string]::IsNullOrWhiteSpace($ConfigPath)) { + $ConfigPath = Join-Path $PSScriptRoot 'release.json' +} -$scriptPath = Join-Path $PSScriptRoot 'Build-PowerForge.ps1' $invokeParams = @{ - Tool = 'PowerForgeWeb' + Tool = @('PowerForgeWeb') + ConfigPath = $ConfigPath Runtime = $Runtime - Configuration = $Configuration Framework = $Framework Flavor = $Flavor - UseStaging = $UseStaging - GitHubUsername = $GitHubUsername - GitHubRepositoryName = $GitHubRepositoryName - GitHubAccessTokenEnvName = $GitHubAccessTokenEnvName - GenerateReleaseNotes = $GenerateReleaseNotes - IsPreRelease = $IsPreRelease } - -if ($PSBoundParameters.ContainsKey('OutDir')) { $invokeParams.OutDir = $OutDir } -if ($ClearOut) { $invokeParams.ClearOut = $true } -if ($Zip) { $invokeParams.Zip = $true } -if ($KeepSymbols) { $invokeParams.KeepSymbols = $true } -if ($KeepDocs) { $invokeParams.KeepDocs = $true } +if ($Plan) { $invokeParams.Plan = $true } +if ($Validate) { $invokeParams.Validate = $true } if ($PublishGitHub) { $invokeParams.PublishGitHub = $true } -if ($PSBoundParameters.ContainsKey('GitHubAccessToken')) { $invokeParams.GitHubAccessToken = $GitHubAccessToken } -if ($PSBoundParameters.ContainsKey('GitHubAccessTokenFilePath')) { $invokeParams.GitHubAccessTokenFilePath = $GitHubAccessTokenFilePath } -if ($PSBoundParameters.ContainsKey('GitHubTagName')) { $invokeParams.GitHubTagName = $GitHubTagName } -if ($PSBoundParameters.ContainsKey('GitHubReleaseName')) { $invokeParams.GitHubReleaseName = $GitHubReleaseName } -& $scriptPath @invokeParams -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE -} +& (Join-Path $PSScriptRoot 'Build-PowerForge.ps1') @invokeParams +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/Build/Build-Project.ps1 b/Build/Build-Project.ps1 index a4746073..e27cc927 100644 --- a/Build/Build-Project.ps1 +++ b/Build/Build-Project.ps1 @@ -1,23 +1,41 @@ param( - [string] $ConfigPath = "$PSScriptRoot\project.build.json", - [Nullable[bool]] $UpdateVersions, - [Nullable[bool]] $Build, - [Nullable[bool]] $PublishNuget = $false, - [Nullable[bool]] $PublishGitHub = $false, - [Nullable[bool]] $Plan, - [string] $PlanPath + [string] $ConfigPath, + [switch] $Plan, + [switch] $Validate, + [switch] $PackagesOnly, + [switch] $ToolsOnly, + [switch] $PublishNuget, + [switch] $PublishGitHub, + [switch] $PublishToolGitHub, + [string[]] $Target, + [string[]] $Runtime, + [string[]] $Framework, + [ValidateSet('SingleContained', 'SingleFx', 'Portable', 'Fx')] + [string[]] $Flavor ) -Import-Module PSPublishModule -Force -ErrorAction Stop - -$invokeParams = @{ - ConfigPath = $ConfigPath +if (-not $PSBoundParameters.ContainsKey('ConfigPath') -or [string]::IsNullOrWhiteSpace($ConfigPath)) { + $ConfigPath = Join-Path $PSScriptRoot 'release.json' } -if ($null -ne $UpdateVersions) { $invokeParams.UpdateVersions = $UpdateVersions } -if ($null -ne $Build) { $invokeParams.Build = $Build } -if ($null -ne $PublishNuget) { $invokeParams.PublishNuget = $PublishNuget } -if ($null -ne $PublishGitHub) { $invokeParams.PublishGitHub = $PublishGitHub } -if ($null -ne $Plan) { $invokeParams.Plan = $Plan } -if ($PlanPath) { $invokeParams.PlanPath = $PlanPath } -Invoke-ProjectBuild @invokeParams +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$project = Join-Path $repoRoot 'PowerForge.Cli\PowerForge.Cli.csproj' + +$dotnetArgs = @( + 'run', '--project', $project, '-c', 'Release', '--framework', 'net10.0', '--no-launch-profile', '--', + 'release', '--config', $ConfigPath +) +if ($Plan) { $dotnetArgs += '--plan' } +if ($Validate) { $dotnetArgs += '--validate' } +if ($PackagesOnly) { $dotnetArgs += '--packages-only' } +if ($ToolsOnly) { $dotnetArgs += '--tools-only' } +if ($PublishNuget) { $dotnetArgs += '--publish-nuget' } +if ($PublishGitHub) { $dotnetArgs += '--publish-project-github' } +if ($PublishToolGitHub) { $dotnetArgs += '--publish-tool-github' } +foreach ($entry in $Target) { $dotnetArgs += @('--target', $entry) } +foreach ($entry in $Runtime) { $dotnetArgs += @('--rid', $entry) } +foreach ($entry in $Framework) { $dotnetArgs += @('--framework', $entry) } +foreach ($entry in $Flavor) { $dotnetArgs += @('--flavor', $entry) } + +dotnet @dotnetArgs +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/Build/release.json b/Build/release.json new file mode 100644 index 00000000..d1fd5342 --- /dev/null +++ b/Build/release.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/powerforge.release.schema.json", + "SchemaVersion": 1, + "Packages": { + "RootPath": "..", + "ExpectedVersionMap": { + "PowerForge": "1.0.X", + "PowerForge.PowerShell": "1.0.X", + "PowerForge.Cli": "1.0.X", + "PowerForge.Blazor": "1.0.X", + "PowerForge.Web": "1.0.X", + "PowerForge.Web.Cli": "1.0.X" + }, + "ExpectedVersionMapAsInclude": true, + "ExpectedVersionMapUseWildcards": false, + "Configuration": "Release", + "StagingPath": "Artefacts/ProjectBuild", + "CleanStaging": true, + "PlanOutputPath": "Artefacts/ProjectBuild/project.build.plan.json", + "UpdateVersions": true, + "Build": true, + "PublishNuget": false, + "PublishGitHub": false, + "CreateReleaseZip": true, + "CertificateThumbprint": "483292C9E317AA13B07BB7A96AE9D1A5ED9E7703", + "CertificateStore": "CurrentUser", + "TimeStampServer": "http://timestamp.digicert.com", + "PublishSource": "https://api.nuget.org/v3/index.json", + "PublishApiKeyFilePath": "C:\\Support\\Important\\NugetOrgEvotec.txt", + "SkipDuplicate": true, + "PublishFailFast": true, + "GitHubAccessTokenFilePath": "C:\\Support\\Important\\GithubAPI.txt", + "GitHubUsername": "EvotecIT", + "GitHubRepositoryName": "PSPublishModule", + "GitHubIsPreRelease": false, + "GitHubIncludeProjectNameInTag": false, + "GitHubGenerateReleaseNotes": true, + "GitHubReleaseMode": "Single", + "GitHubPrimaryProject": "PowerForge.Cli", + "GitHubTagTemplate": "{Repo}-v{PrimaryVersion}", + "GitHubReleaseName": "{Repo} {PrimaryVersion}" + }, + "Tools": { + "ProjectRoot": "..", + "Configuration": "Release", + "Targets": [ + { + "Name": "PowerForge", + "ProjectPath": "PowerForge.Cli/PowerForge.Cli.csproj", + "OutputName": "PowerForge", + "CommandAlias": "powerforge", + "Runtimes": [ "win-x64", "linux-x64", "linux-arm64", "osx-x64", "osx-arm64" ], + "Frameworks": [ "net10.0" ], + "Flavor": "SingleContained", + "ArtifactRootPath": "Artifacts/PowerForge", + "Zip": true, + "UseStaging": true, + "ClearOutput": true + }, + { + "Name": "PowerForgeWeb", + "ProjectPath": "PowerForge.Web.Cli/PowerForge.Web.Cli.csproj", + "OutputName": "PowerForgeWeb", + "CommandAlias": "powerforge-web", + "Runtimes": [ "win-x64", "linux-x64", "linux-arm64", "osx-x64", "osx-arm64" ], + "Frameworks": [ "net10.0" ], + "Flavor": "SingleContained", + "ArtifactRootPath": "Artifacts/PowerForgeWeb", + "Zip": true, + "UseStaging": true, + "ClearOutput": true + } + ], + "GitHub": { + "Publish": false, + "Owner": "EvotecIT", + "Repository": "PSPublishModule", + "TokenFilePath": "C:\\Support\\Important\\GithubAPI.txt", + "GenerateReleaseNotes": true, + "IsPreRelease": false, + "TagTemplate": "{Target}-v{Version}", + "ReleaseNameTemplate": "{Target} {Version}" + } + } +} diff --git a/Docs/PSPublishModule.ProjectBuild.md b/Docs/PSPublishModule.ProjectBuild.md index ab83b539..1f67820e 100644 --- a/Docs/PSPublishModule.ProjectBuild.md +++ b/Docs/PSPublishModule.ProjectBuild.md @@ -1,6 +1,8 @@ # Project Build (Repository Pipeline) This document describes the JSON configuration consumed by `Invoke-ProjectBuild` and the behavior it drives. +For the unified repo-level entrypoint that combines package and downloadable tool releases in one file, +see `Build/release.json` and `powerforge release`. For module help/docs generation workflow (`Invoke-ModuleBuild`, `New-ConfigurationDocumentation`, `about_*` topics), see `Docs/PSPublishModule.ModuleDocumentation.md`. @@ -8,6 +10,11 @@ see `Docs/PSPublishModule.ModuleDocumentation.md`. Schema - Location: `Schemas/project.build.schema.json` +Unified release entrypoint +- Schema: `Schemas/powerforge.release.schema.json` +- Wrapper: `Build/Build-Project.ps1` +- CLI: `powerforge release --config .\Build\release.json` + Overview - The build pipeline discovers .NET projects, resolves versions, optionally updates csproj files, packs and signs NuGet packages, and can publish to NuGet and GitHub. diff --git a/PowerForge.Cli/PowerForgeCliJsonContext.cs b/PowerForge.Cli/PowerForgeCliJsonContext.cs index a1686c61..84cd0cba 100644 --- a/PowerForge.Cli/PowerForgeCliJsonContext.cs +++ b/PowerForge.Cli/PowerForgeCliJsonContext.cs @@ -38,6 +38,13 @@ namespace PowerForge.Cli; [JsonSerializable(typeof(GitHubArtifactCleanupResult))] [JsonSerializable(typeof(GitHubActionsCacheCleanupResult))] [JsonSerializable(typeof(RunnerHousekeepingResult))] +[JsonSerializable(typeof(PowerForgeReleaseSpec))] +[JsonSerializable(typeof(PowerForgeReleaseResult))] +[JsonSerializable(typeof(PowerForgeReleaseRequest))] +[JsonSerializable(typeof(PowerForgeToolReleaseSpec))] +[JsonSerializable(typeof(PowerForgeToolReleasePlan))] +[JsonSerializable(typeof(PowerForgeToolReleaseResult))] +[JsonSerializable(typeof(PowerForgeToolGitHubReleaseResult))] [JsonSerializable(typeof(ArtefactBuildResult[]))] [JsonSerializable(typeof(NormalizationResult[]))] [JsonSerializable(typeof(FormatterResult[]))] diff --git a/PowerForge.Cli/Program.Command.Release.cs b/PowerForge.Cli/Program.Command.Release.cs new file mode 100644 index 00000000..cad9a67c --- /dev/null +++ b/PowerForge.Cli/Program.Command.Release.cs @@ -0,0 +1,178 @@ +using PowerForge; +using PowerForge.Cli; + +internal static partial class Program +{ + private const string ReleaseUsage = + "Usage: powerforge release [--config ] [--plan] [--validate] [--packages-only] [--tools-only] [--publish-nuget] [--publish-project-github] [--publish-tool-github] [--target ] [--rid ] [--framework ] [--flavor [,<...>]] [--output json]"; + + private static int CommandRelease(string[] filteredArgs, CliOptions cli, ILogger logger) + { + var argv = filteredArgs.Skip(1).ToArray(); + var outputJson = IsJsonOutput(argv); + var planOnly = argv.Any(a => a.Equals("--plan", StringComparison.OrdinalIgnoreCase) || a.Equals("--dry-run", StringComparison.OrdinalIgnoreCase)); + var validateOnly = argv.Any(a => a.Equals("--validate", StringComparison.OrdinalIgnoreCase)); + var packagesOnly = argv.Any(a => a.Equals("--packages-only", StringComparison.OrdinalIgnoreCase)); + var toolsOnly = argv.Any(a => a.Equals("--tools-only", StringComparison.OrdinalIgnoreCase)); + + if (packagesOnly && toolsOnly) + { + return WriteReleaseError(outputJson, "release", 2, "Use either --packages-only or --tools-only, not both.", logger); + } + + var configPath = TryGetOptionValue(argv, "--config"); + if (string.IsNullOrWhiteSpace(configPath)) + configPath = FindDefaultReleaseConfig(Directory.GetCurrentDirectory()); + + if (string.IsNullOrWhiteSpace(configPath)) + { + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = "release", + Success = false, + ExitCode = 2, + Error = "Missing --config and no default release config found." + }); + return 2; + } + + Console.WriteLine(ReleaseUsage); + return 2; + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var loaded = LoadPowerForgeReleaseSpecWithPath(configPath); + var spec = loaded.Value; + var fullConfigPath = loaded.FullPath; + + var flavors = ParseCsvOptionValues(argv, "--flavor") + .Select(ParsePowerForgeToolReleaseFlavor) + .Distinct() + .ToArray(); + + var request = new PowerForgeReleaseRequest + { + ConfigPath = fullConfigPath, + PlanOnly = planOnly, + ValidateOnly = validateOnly, + PackagesOnly = packagesOnly, + ToolsOnly = toolsOnly, + PublishNuget = argv.Any(a => a.Equals("--publish-nuget", StringComparison.OrdinalIgnoreCase)) ? true : null, + PublishProjectGitHub = argv.Any(a => a.Equals("--publish-project-github", StringComparison.OrdinalIgnoreCase)) ? true : null, + PublishToolGitHub = argv.Any(a => a.Equals("--publish-tool-github", StringComparison.OrdinalIgnoreCase)) ? true : null, + Targets = ParseCsvOptionValues(argv, "--target"), + Runtimes = ParseCsvOptionValues(argv, "--rid", "--runtime"), + Frameworks = ParseCsvOptionValues(argv, "--framework"), + Flavors = flavors + }; + + var service = new PowerForgeReleaseService(cmdLogger); + var result = RunWithStatus(outputJson, cli, "Running unified release workflow", () => service.Execute(spec, request)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = "release", + Success = result.Success, + ExitCode = exitCode, + Error = result.ErrorMessage, + Config = "release", + ConfigPath = fullConfigPath, + Spec = CliJson.SerializeToElement(spec, CliJson.Context.PowerForgeReleaseSpec), + Result = CliJson.SerializeToElement(result, CliJson.Context.PowerForgeReleaseResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; + } + + if (!result.Success) + { + cmdLogger.Error(result.ErrorMessage ?? "Release workflow failed."); + return exitCode; + } + + if (result.Packages is not null) + { + var release = result.Packages.Result.Release; + var packageCount = release?.Projects?.Count(project => project.IsPackable) ?? 0; + cmdLogger.Success($"Packages: {(planOnly || validateOnly ? "planned" : "completed")} ({packageCount} project(s))."); + if (!string.IsNullOrWhiteSpace(result.Packages.PlanOutputPath)) + cmdLogger.Info($"Package plan: {result.Packages.PlanOutputPath}"); + } + + if (result.ToolPlan is not null) + { + var comboCount = result.ToolPlan.Targets.Sum(target => target.Combinations?.Length ?? 0); + cmdLogger.Success($"Tools: {(planOnly || validateOnly ? "planned" : "completed")} ({comboCount} combination(s))."); + } + + if (result.Tools is not null) + { + foreach (var artefact in result.Tools.Artefacts) + { + cmdLogger.Info($" -> {artefact.Target} {artefact.Framework} {artefact.Runtime} {artefact.Flavor}: {artefact.OutputPath}"); + if (!string.IsNullOrWhiteSpace(artefact.ZipPath)) + cmdLogger.Info($" zip: {artefact.ZipPath}"); + } + + foreach (var manifest in result.Tools.ManifestPaths) + cmdLogger.Info($"Manifest: {manifest}"); + } + + foreach (var release in result.ToolGitHubReleases) + { + if (release.Success) + cmdLogger.Info($"GitHub release -> {release.Target} {release.TagName} {release.ReleaseUrl}"); + else + cmdLogger.Warn($"GitHub release failed for {release.Target}: {release.ErrorMessage}"); + } + + return exitCode; + } + catch (Exception ex) + { + return WriteReleaseError(outputJson, "release", 1, ex.Message, logger); + } + } + + private static int WriteReleaseError(bool outputJson, string command, int exitCode, string message, ILogger logger) + { + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = command, + Success = false, + ExitCode = exitCode, + Error = message + }); + return exitCode; + } + + logger.Error(message); + return exitCode; + } + + private static PowerForgeToolReleaseFlavor ParsePowerForgeToolReleaseFlavor(string? value) + { + var raw = (value ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(raw)) + throw new ArgumentException("Flavor must not be empty.", nameof(value)); + + if (Enum.TryParse(raw, ignoreCase: true, out PowerForgeToolReleaseFlavor flavor)) + return flavor; + + throw new ArgumentException( + $"Unknown tool release flavor: {raw}. Expected one of: {string.Join(", ", Enum.GetNames(typeof(PowerForgeToolReleaseFlavor)))}", + nameof(value)); + } +} diff --git a/PowerForge.Cli/Program.Helpers.IOAndJson.cs b/PowerForge.Cli/Program.Helpers.IOAndJson.cs index b3e12b73..b8383e8d 100644 --- a/PowerForge.Cli/Program.Helpers.IOAndJson.cs +++ b/PowerForge.Cli/Program.Helpers.IOAndJson.cs @@ -181,6 +181,32 @@ static void ResolvePipelineSpecPaths(ModulePipelineSpec spec, string configFullP return null; } + static string? FindDefaultReleaseConfig(string baseDir) + { + var candidates = new[] + { + "powerforge.release.json", + Path.Combine(".powerforge", "release.json"), + Path.Combine("Build", "release.json"), + "release.json" + }; + + foreach (var dir in EnumerateSelfAndParents(baseDir)) + { + foreach (var rel in candidates) + { + try + { + var full = Path.GetFullPath(Path.Combine(dir, rel)); + if (File.Exists(full)) return full; + } + catch { /* ignore */ } + } + } + + return null; + } + static IEnumerable EnumerateSelfAndParents(string? baseDir) { string current; @@ -271,6 +297,14 @@ static string ResolveExistingFilePath(string path) return (spec, full); } + static (PowerForgeReleaseSpec Value, string FullPath) LoadPowerForgeReleaseSpecWithPath(string path) + { + var full = ResolveExistingFilePath(path); + var json = File.ReadAllText(full); + var spec = CliJson.DeserializeOrThrow(json, CliJson.Context.PowerForgeReleaseSpec, full); + return (spec, full); + } + static (ModuleInstallSpec Value, string FullPath) LoadInstallSpecWithPath(string path) { var full = ResolveExistingFilePath(path); diff --git a/PowerForge.Cli/Program.Helpers.cs b/PowerForge.Cli/Program.Helpers.cs index 65b10c58..7bf5a0f2 100644 --- a/PowerForge.Cli/Program.Helpers.cs +++ b/PowerForge.Cli/Program.Helpers.cs @@ -17,6 +17,8 @@ powerforge pack [--config ] [--project-root ] [--out powerforge template --script [--out ] [--project-root ] [--powershell ] [--output json] powerforge dotnet publish [--config ] [--project-root ] [--profile ] [--plan] [--validate] [--output json] [--target ] [--rid ] [--framework ] [--style ] [--matrix ] [--skip-restore] [--skip-build] powerforge dotnet scaffold [--project-root ] [--project ] [--target ] [--framework ] [--rid ] [--style [,...]] [--configuration ] [--out ] [--overwrite] [--no-schema] [--output json] + powerforge release [--config ] [--plan] [--validate] [--packages-only] [--tools-only] [--publish-nuget] [--publish-project-github] [--publish-tool-github] + [--target ] [--rid ] [--framework ] [--flavor [,<...>]] [--output json] powerforge normalize Normalize encodings and line endings [--output json] powerforge format Format scripts via PSScriptAnalyzer (out-of-proc) [--output json] powerforge test [--project-root ] [--test-path ] [--format Detailed|Normal|Minimal] [--coverage] [--force] @@ -53,6 +55,7 @@ Default config discovery (when --config is omitted): Searches for powerforge.json / powerforge.pipeline.json / .powerforge/pipeline.json in the current directory and parent directories. DotNet publish searches for powerforge.dotnetpublish.json / .powerforge/dotnetpublish.json. + Release searches for powerforge.release.json / .powerforge/release.json / Build/release.json. "); } diff --git a/PowerForge.Cli/Program.cs b/PowerForge.Cli/Program.cs index a5956619..0c4a2e6e 100644 --- a/PowerForge.Cli/Program.cs +++ b/PowerForge.Cli/Program.cs @@ -67,6 +67,8 @@ public static int Main(string[] args) return CommandPack(filteredArgs, cli, logger); case "dotnet": return CommandDotNet(filteredArgs, cli, logger); + case "release": + return CommandRelease(filteredArgs, cli, logger); case "github": return CommandGitHub(filteredArgs, cli, logger); case "normalize": diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs new file mode 100644 index 00000000..b9a57d90 --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs @@ -0,0 +1,239 @@ +using System.Text; +using System.Diagnostics; + +namespace PowerForge.Tests; + +public sealed class PowerForgeReleaseServiceTests +{ + [Fact] + public void ToolReleasePlan_AppliesOverridesAcrossSelectedTarget() + { + var root = CreateSandbox(); + try + { + var projectPath = Path.Combine(root, "PowerForge.Cli.csproj"); + File.WriteAllText(projectPath, """ + + + net10.0 + 1.2.3 + + +""", new UTF8Encoding(false)); + + var service = new PowerForgeToolReleaseService(new NullLogger()); + var plan = service.Plan( + new PowerForgeToolReleaseSpec + { + ProjectRoot = ".", + Targets = new[] + { + new PowerForgeToolReleaseTarget + { + Name = "PowerForge", + ProjectPath = "PowerForge.Cli.csproj", + OutputName = "PowerForge", + Frameworks = new[] { "net10.0" }, + Runtimes = new[] { "win-x64", "linux-x64" }, + Flavor = PowerForgeToolReleaseFlavor.SingleContained + } + } + }, + Path.Combine(root, "release.json"), + new PowerForgeReleaseRequest + { + Targets = new[] { "PowerForge" }, + Runtimes = new[] { "osx-arm64" }, + Frameworks = new[] { "net8.0" }, + Flavors = new[] { PowerForgeToolReleaseFlavor.SingleFx } + }); + + var target = Assert.Single(plan.Targets); + var combination = Assert.Single(target.Combinations); + Assert.Equal("1.2.3", target.Version); + Assert.Equal("osx-arm64", combination.Runtime); + Assert.Equal("net8.0", combination.Framework); + Assert.Equal(PowerForgeToolReleaseFlavor.SingleFx, combination.Flavor); + Assert.Contains("PowerForge", combination.OutputPath, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_GroupsToolAssetsIntoSingleGitHubReleasePerTarget() + { + var zipA = Path.GetTempFileName(); + var zipB = Path.GetTempFileName(); + try + { + var publishCalls = new List(); + var service = new PowerForgeReleaseService( + new NullLogger(), + executePackages: (_, _, _) => throw new InvalidOperationException("Packages should not run."), + planTools: (_, _, _) => new PowerForgeToolReleasePlan + { + ProjectRoot = Path.GetTempPath(), + Configuration = "Release", + Targets = new[] + { + new PowerForgeToolReleaseTargetPlan + { + Name = "PowerForge", + ProjectPath = "PowerForge.Cli.csproj", + OutputName = "PowerForge", + Version = "1.2.3", + ArtifactRootPath = Path.GetTempPath(), + Combinations = new[] + { + new PowerForgeToolReleaseCombinationPlan + { + Runtime = "win-x64", + Framework = "net10.0", + Flavor = PowerForgeToolReleaseFlavor.SingleContained, + OutputPath = Path.GetTempPath(), + ZipPath = zipA + } + } + } + } + }, + runTools: _ => new PowerForgeToolReleaseResult + { + Success = true, + Artefacts = new[] + { + new PowerForgeToolReleaseArtifactResult + { + Target = "PowerForge", + Version = "1.2.3", + OutputName = "PowerForge", + Runtime = "win-x64", + Framework = "net10.0", + Flavor = PowerForgeToolReleaseFlavor.SingleContained, + OutputPath = Path.GetTempPath(), + ExecutablePath = Path.Combine(Path.GetTempPath(), "PowerForge.exe"), + ZipPath = zipA + }, + new PowerForgeToolReleaseArtifactResult + { + Target = "PowerForge", + Version = "1.2.3", + OutputName = "PowerForge", + Runtime = "linux-x64", + Framework = "net10.0", + Flavor = PowerForgeToolReleaseFlavor.SingleContained, + OutputPath = Path.GetTempPath(), + ExecutablePath = Path.Combine(Path.GetTempPath(), "PowerForge"), + ZipPath = zipB + } + } + }, + publishGitHubRelease: request => + { + publishCalls.Add(request); + return new GitHubReleasePublishResult + { + Succeeded = true, + HtmlUrl = "https://example.test/release", + ReusedExistingRelease = true + }; + }); + + var result = service.Execute( + new PowerForgeReleaseSpec + { + Tools = new PowerForgeToolReleaseSpec + { + GitHub = new PowerForgeToolReleaseGitHubOptions + { + Publish = true, + Owner = "EvotecIT", + Repository = "PSPublishModule", + Token = "token", + TagTemplate = "{Target}-v{Version}", + ReleaseNameTemplate = "{Target} {Version}" + } + } + }, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(Path.GetTempPath(), "release.json"), + ToolsOnly = true + }); + + Assert.True(result.Success); + var publish = Assert.Single(publishCalls); + Assert.Equal("PowerForge-v1.2.3", publish.TagName); + Assert.Equal("PowerForge 1.2.3", publish.ReleaseName); + Assert.Equal(2, publish.AssetFilePaths.Count); + + var release = Assert.Single(result.ToolGitHubReleases); + Assert.True(release.Success); + Assert.Equal(2, release.AssetPaths.Length); + Assert.True(release.ReusedExistingRelease); + } + finally + { + TryDelete(zipA); + TryDelete(zipB); + } + } + + [Fact] + public void ToolReleaseRunProcess_CapturesStdOutAndStdErrWithoutBlocking() + { + var method = typeof(PowerForgeToolReleaseService).GetMethod("RunProcess", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.NotNull(method); + + var tempScript = Path.Combine(Path.GetTempPath(), $"powerforge-toolrelease-{Guid.NewGuid():N}.cmd"); + try + { + File.WriteAllText(tempScript, "@echo stdout-line\r\n@echo stderr-line 1>&2\r\n", new UTF8Encoding(false)); + + var psi = new ProcessStartInfo + { + FileName = "cmd.exe", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("/c"); + psi.ArgumentList.Add(tempScript); + + var result = method!.Invoke(null, new object?[] { psi }); + Assert.NotNull(result); + + var exitCode = (int)result.GetType().GetProperty("ExitCode")!.GetValue(result)!; + var stdOut = (string)result.GetType().GetProperty("StdOut")!.GetValue(result)!; + var stdErr = (string)result.GetType().GetProperty("StdErr")!.GetValue(result)!; + + Assert.Equal(0, exitCode); + Assert.Contains("stdout-line", stdOut, StringComparison.OrdinalIgnoreCase); + Assert.Contains("stderr-line", stdErr, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (File.Exists(tempScript)) + File.Delete(tempScript); + } + } + + private static string CreateSandbox() + { + var path = Path.Combine(Path.GetTempPath(), "PowerForge.ReleaseTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + else if (File.Exists(path)) + File.Delete(path); + } +} diff --git a/PowerForge.Tests/RunnerHousekeepingServiceTests.cs b/PowerForge.Tests/RunnerHousekeepingServiceTests.cs index c732da7f..fbf27050 100644 --- a/PowerForge.Tests/RunnerHousekeepingServiceTests.cs +++ b/PowerForge.Tests/RunnerHousekeepingServiceTests.cs @@ -100,6 +100,48 @@ public void Clean_Apply_AggressiveModeDeletesOldDirectories() } } + [Fact] + public void GuardedSudoDelete_RejectsTargetsOutsideAllowedRoot() + { + var service = new RunnerHousekeepingService(new NullLogger()); + var method = typeof(RunnerHousekeepingService).GetMethod("EnsureDeleteTargetWithinRoot", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + Assert.NotNull(method); + + var root = CreateSandbox(); + try + { + var allowedRoot = Path.Combine(root, "allowed"); + var outsideTarget = Path.Combine(root, "outside", "temp"); + Directory.CreateDirectory(allowedRoot); + Directory.CreateDirectory(Path.GetDirectoryName(outsideTarget)!); + + var exception = Assert.Throws(() => + method!.Invoke(null, new object?[] { outsideTarget, allowedRoot })); + + Assert.IsType(exception.InnerException); + Assert.Contains("outside", exception.InnerException!.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData(0L, "0.0")] + [InlineData(536_870_912L, "0.5")] + [InlineData(1_610_612_736L, "1.5")] + public void FormatGiB_PreservesFractionalValues(long bytes, string expected) + { + var method = typeof(RunnerHousekeepingService).GetMethod("FormatGiB", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + Assert.NotNull(method); + + var value = method!.Invoke(null, new object?[] { bytes }); + Assert.Equal(expected, Assert.IsType(value)); + } + private static string CreateSandbox() { var path = Path.Combine(Path.GetTempPath(), "PowerForge.RunnerHousekeepingTests", Guid.NewGuid().ToString("N")); diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs new file mode 100644 index 00000000..dc718811 --- /dev/null +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -0,0 +1,94 @@ +using System.Text.Json.Serialization; + +namespace PowerForge; + +/// +/// Unified repository release configuration that can drive package and tool outputs from one JSON file. +/// +internal sealed class PowerForgeReleaseSpec +{ + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + + public int SchemaVersion { get; set; } = 1; + + public ProjectBuildConfiguration? Packages { get; set; } + + public PowerForgeToolReleaseSpec? Tools { get; set; } +} + +/// +/// Host-facing request for executing a unified release workflow. +/// +internal sealed class PowerForgeReleaseRequest +{ + public string ConfigPath { get; set; } = string.Empty; + + public bool PlanOnly { get; set; } + + public bool ValidateOnly { get; set; } + + public bool PackagesOnly { get; set; } + + public bool ToolsOnly { get; set; } + + public bool? PublishNuget { get; set; } + + public bool? PublishProjectGitHub { get; set; } + + public bool? PublishToolGitHub { get; set; } + + public string[] Targets { get; set; } = Array.Empty(); + + public string[] Runtimes { get; set; } = Array.Empty(); + + public string[] Frameworks { get; set; } = Array.Empty(); + + public PowerForgeToolReleaseFlavor[] Flavors { get; set; } = Array.Empty(); +} + +/// +/// Aggregate result for a unified release run. +/// +internal sealed class PowerForgeReleaseResult +{ + public bool Success { get; set; } + + public string? ErrorMessage { get; set; } + + public string ConfigPath { get; set; } = string.Empty; + + public ProjectBuildHostExecutionResult? Packages { get; set; } + + public PowerForgeToolReleasePlan? ToolPlan { get; set; } + + public PowerForgeToolReleaseResult? Tools { get; set; } + + public PowerForgeToolGitHubReleaseResult[] ToolGitHubReleases { get; set; } = Array.Empty(); +} + +/// +/// GitHub publishing result for one tool release group. +/// +internal sealed class PowerForgeToolGitHubReleaseResult +{ + public string Target { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string TagName { get; set; } = string.Empty; + + public string ReleaseName { get; set; } = string.Empty; + + public string[] AssetPaths { get; set; } = Array.Empty(); + + public bool Success { get; set; } + + public string? ReleaseUrl { get; set; } + + public bool ReusedExistingRelease { get; set; } + + public string? ErrorMessage { get; set; } + + public string[] SkippedExistingAssets { get; set; } = Array.Empty(); +} diff --git a/PowerForge/Models/PowerForgeToolRelease.cs b/PowerForge/Models/PowerForgeToolRelease.cs new file mode 100644 index 00000000..06e343e2 --- /dev/null +++ b/PowerForge/Models/PowerForgeToolRelease.cs @@ -0,0 +1,218 @@ +using System.Text.Json.Serialization; + +namespace PowerForge; + +/// +/// Downloadable tool release configuration used for runtime-specific executables. +/// +internal sealed class PowerForgeToolReleaseSpec +{ + public string? ProjectRoot { get; set; } + + public string Configuration { get; set; } = "Release"; + + public PowerForgeToolReleaseTarget[] Targets { get; set; } = Array.Empty(); + + public PowerForgeToolReleaseGitHubOptions GitHub { get; set; } = new(); +} + +/// +/// One named tool target to publish as downloadable executables. +/// +internal sealed class PowerForgeToolReleaseTarget +{ + public string Name { get; set; } = string.Empty; + + public string ProjectPath { get; set; } = string.Empty; + + public string OutputName { get; set; } = string.Empty; + + public string? CommandAlias { get; set; } + + public string[] Runtimes { get; set; } = Array.Empty(); + + public string[] Frameworks { get; set; } = Array.Empty(); + + public PowerForgeToolReleaseFlavor Flavor { get; set; } = PowerForgeToolReleaseFlavor.SingleContained; + + public PowerForgeToolReleaseFlavor[] Flavors { get; set; } = Array.Empty(); + + public string? ArtifactRootPath { get; set; } + + public string? OutputPath { get; set; } + + public bool UseStaging { get; set; } = true; + + public bool ClearOutput { get; set; } = true; + + public bool Zip { get; set; } = true; + + public string? ZipPath { get; set; } + + public string? ZipNameTemplate { get; set; } + + public bool KeepSymbols { get; set; } + + public bool KeepDocs { get; set; } + + public bool CreateCommandAliasOnUnix { get; set; } = true; + + public Dictionary? MsBuildProperties { get; set; } +} + +/// +/// GitHub release settings for tool artefacts. +/// +internal sealed class PowerForgeToolReleaseGitHubOptions +{ + public bool Publish { get; set; } + + public string? Owner { get; set; } + + public string? Repository { get; set; } + + public string? Token { get; set; } + + public string? TokenFilePath { get; set; } + + public string? TokenEnvName { get; set; } + + public bool GenerateReleaseNotes { get; set; } = true; + + public bool IsPreRelease { get; set; } + + public string? TagTemplate { get; set; } + + public string? ReleaseNameTemplate { get; set; } +} + +/// +/// Supported publish flavors for downloadable tool binaries. +/// +internal enum PowerForgeToolReleaseFlavor +{ + SingleContained, + SingleFx, + Portable, + Fx +} + +/// +/// Planned tool release execution. +/// +internal sealed class PowerForgeToolReleasePlan +{ + public string ProjectRoot { get; set; } = string.Empty; + + public string Configuration { get; set; } = "Release"; + + public PowerForgeToolReleaseTargetPlan[] Targets { get; set; } = Array.Empty(); +} + +/// +/// Planned target entry. +/// +internal sealed class PowerForgeToolReleaseTargetPlan +{ + public string Name { get; set; } = string.Empty; + + public string ProjectPath { get; set; } = string.Empty; + + public string OutputName { get; set; } = string.Empty; + + public string? CommandAlias { get; set; } + + public string Version { get; set; } = string.Empty; + + public string ArtifactRootPath { get; set; } = string.Empty; + + public bool UseStaging { get; set; } + + public bool ClearOutput { get; set; } + + public bool Zip { get; set; } + + public bool KeepSymbols { get; set; } + + public bool KeepDocs { get; set; } + + public bool CreateCommandAliasOnUnix { get; set; } + + public Dictionary MsBuildProperties { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public PowerForgeToolReleaseCombinationPlan[] Combinations { get; set; } = Array.Empty(); +} + +/// +/// Planned target/runtime/framework/flavor combination. +/// +internal sealed class PowerForgeToolReleaseCombinationPlan +{ + public string Runtime { get; set; } = string.Empty; + + public string Framework { get; set; } = string.Empty; + + public PowerForgeToolReleaseFlavor Flavor { get; set; } + + public string OutputPath { get; set; } = string.Empty; + + public string? ZipPath { get; set; } +} + +/// +/// Result of executing tool releases. +/// +internal sealed class PowerForgeToolReleaseResult +{ + public bool Success { get; set; } + + public string? ErrorMessage { get; set; } + + public PowerForgeToolReleaseArtifactResult[] Artefacts { get; set; } = Array.Empty(); + + public string[] ManifestPaths { get; set; } = Array.Empty(); +} + +/// +/// One produced downloadable tool artefact. +/// +internal sealed class PowerForgeToolReleaseArtifactResult +{ + public string Target { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string OutputName { get; set; } = string.Empty; + + public string Runtime { get; set; } = string.Empty; + + public string Framework { get; set; } = string.Empty; + + public PowerForgeToolReleaseFlavor Flavor { get; set; } + + public string OutputPath { get; set; } = string.Empty; + + public string ExecutablePath { get; set; } = string.Empty; + + public string? CommandAliasPath { get; set; } + + public string? ZipPath { get; set; } + + public int Files { get; set; } + + public long TotalBytes { get; set; } +} + +/// +/// Per-target manifest written after tool builds complete. +/// +internal sealed class PowerForgeToolReleaseManifest +{ + public string Target { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string OutputName { get; set; } = string.Empty; + + public PowerForgeToolReleaseArtifactResult[] Artefacts { get; set; } = Array.Empty(); +} diff --git a/PowerForge/Services/GitHubActionsCacheCleanupService.cs b/PowerForge/Services/GitHubActionsCacheCleanupService.cs index 3c04cdad..99b66e56 100644 --- a/PowerForge/Services/GitHubActionsCacheCleanupService.cs +++ b/PowerForge/Services/GitHubActionsCacheCleanupService.cs @@ -7,6 +7,7 @@ using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace PowerForge; @@ -36,12 +37,20 @@ public GitHubActionsCacheCleanupService(ILogger logger, HttpClient? client = nul /// Cleanup specification. /// Run summary with planned/deleted items and counters. public GitHubActionsCacheCleanupResult Prune(GitHubActionsCacheCleanupSpec spec) + => PruneAsync(spec).ConfigureAwait(false).GetAwaiter().GetResult(); + + /// + /// Executes a cleanup run against GitHub Actions caches. + /// + /// Cleanup specification. + /// Run summary with planned/deleted items and counters. + public async Task PruneAsync(GitHubActionsCacheCleanupSpec spec) { if (spec is null) throw new ArgumentNullException(nameof(spec)); var normalized = NormalizeSpec(spec); - var usageBefore = TryGetUsage(normalized.ApiBaseUri, normalized.Repository, normalized.Token); - var allCaches = ListCaches(normalized.ApiBaseUri, normalized.Repository, normalized.Token, normalized.PageSize); + var usageBefore = await TryGetUsageAsync(normalized.ApiBaseUri, normalized.Repository, normalized.Token).ConfigureAwait(false); + var allCaches = await ListCachesAsync(normalized.ApiBaseUri, normalized.Repository, normalized.Token, normalized.PageSize).ConfigureAwait(false); var now = DateTimeOffset.UtcNow; var ageCutoff = normalized.MaxAgeDays is > 0 ? now.AddDays(-normalized.MaxAgeDays.Value) @@ -123,7 +132,7 @@ public GitHubActionsCacheCleanupResult Prune(GitHubActionsCacheCleanupSpec spec) foreach (var item in orderedPlanned) { - var deleteResult = DeleteCache(normalized.ApiBaseUri, normalized.Repository, normalized.Token, item.Id); + var deleteResult = await DeleteCacheAsync(normalized.ApiBaseUri, normalized.Repository, normalized.Token, item.Id).ConfigureAwait(false); if (deleteResult.Ok) { deleted.Add(item); @@ -142,7 +151,7 @@ public GitHubActionsCacheCleanupResult Prune(GitHubActionsCacheCleanupSpec spec) result.DeletedCaches = deleted.Count; result.DeletedBytes = deleted.Sum(c => c.SizeInBytes); result.FailedDeletes = failed.Count; - result.UsageAfter = TryGetUsage(normalized.ApiBaseUri, normalized.Repository, normalized.Token); + result.UsageAfter = await TryGetUsageAsync(normalized.ApiBaseUri, normalized.Repository, normalized.Token).ConfigureAwait(false); result.Success = failed.Count == 0 || !normalized.FailOnDeleteError; if (!result.Success) @@ -205,7 +214,7 @@ private NormalizedSpec NormalizeSpec(GitHubActionsCacheCleanupSpec spec) }; } - private GitHubActionsCacheUsage? TryGetUsage(Uri apiBaseUri, string repository, string token) + private async Task TryGetUsageAsync(Uri apiBaseUri, string repository, string token) { try { @@ -213,8 +222,8 @@ private NormalizedSpec NormalizeSpec(GitHubActionsCacheCleanupSpec spec) using var request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - using var response = _client.SendAsync(request).ConfigureAwait(false).GetAwaiter().GetResult(); - var body = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + using var response = await _client.SendAsync(request).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.Verbose($"GitHub cache usage lookup failed: HTTP {(int)response.StatusCode}."); @@ -236,7 +245,7 @@ private NormalizedSpec NormalizeSpec(GitHubActionsCacheCleanupSpec spec) } } - private GitHubActionsCacheRecord[] ListCaches(Uri apiBaseUri, string repository, string token, int pageSize) + private async Task ListCachesAsync(Uri apiBaseUri, string repository, string token, int pageSize) { var records = new List(); var page = 1; @@ -248,8 +257,8 @@ private GitHubActionsCacheRecord[] ListCaches(Uri apiBaseUri, string repository, using var request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - using var response = _client.SendAsync(request).ConfigureAwait(false).GetAwaiter().GetResult(); - var body = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + using var response = await _client.SendAsync(request).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (!response.IsSuccessStatusCode) throw BuildHttpFailure("listing caches", response, body); @@ -280,17 +289,17 @@ private GitHubActionsCacheRecord[] ListCaches(Uri apiBaseUri, string repository, return records.ToArray(); } - private (bool Ok, int? StatusCode, string? Error) DeleteCache(Uri apiBaseUri, string repository, string token, long cacheId) + private async Task<(bool Ok, int? StatusCode, string? Error)> DeleteCacheAsync(Uri apiBaseUri, string repository, string token, long cacheId) { var uri = BuildApiUri(apiBaseUri, repository, $"actions/caches/{cacheId}"); using var request = new HttpRequestMessage(HttpMethod.Delete, uri); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - using var response = _client.SendAsync(request).ConfigureAwait(false).GetAwaiter().GetResult(); + using var response = await _client.SendAsync(request).ConfigureAwait(false); if (response.IsSuccessStatusCode) return (true, (int)response.StatusCode, null); - var body = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); var error = BuildHttpFailure("deleting cache", response, body).Message; return (false, (int)response.StatusCode, error); } diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs new file mode 100644 index 00000000..869f179c --- /dev/null +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -0,0 +1,286 @@ +namespace PowerForge; + +/// +/// Orchestrates package and tool release workflows from one unified configuration. +/// +internal sealed class PowerForgeReleaseService +{ + private readonly ILogger _logger; + private readonly Func _executePackages; + private readonly Func _planTools; + private readonly Func _runTools; + private readonly Func _publishGitHubRelease; + + /// + /// Creates a new unified release service. + /// + public PowerForgeReleaseService(ILogger logger) + : this( + logger, + (request, config, configPath) => new ProjectBuildHostService(logger).Execute(request, config, configPath), + (spec, configPath, request) => new PowerForgeToolReleaseService(logger).Plan(spec, configPath, request), + plan => new PowerForgeToolReleaseService(logger).Run(plan), + publishRequest => new GitHubReleasePublisher(logger).PublishRelease(publishRequest)) + { + } + + internal PowerForgeReleaseService( + ILogger logger, + Func executePackages, + Func planTools, + Func runTools, + Func publishGitHubRelease) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _executePackages = executePackages ?? throw new ArgumentNullException(nameof(executePackages)); + _planTools = planTools ?? throw new ArgumentNullException(nameof(planTools)); + _runTools = runTools ?? throw new ArgumentNullException(nameof(runTools)); + _publishGitHubRelease = publishGitHubRelease ?? throw new ArgumentNullException(nameof(publishGitHubRelease)); + } + + /// + /// Executes the unified release workflow. + /// + public PowerForgeReleaseResult Execute(PowerForgeReleaseSpec spec, PowerForgeReleaseRequest request) + { + if (spec is null) + throw new ArgumentNullException(nameof(spec)); + if (request is null) + throw new ArgumentNullException(nameof(request)); + + if (string.IsNullOrWhiteSpace(request.ConfigPath)) + throw new ArgumentException("ConfigPath is required.", nameof(request)); + + var configPath = Path.GetFullPath(request.ConfigPath.Trim().Trim('"')); + var configDirectory = Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory(); + var runPackages = !request.ToolsOnly && spec.Packages is not null; + var runTools = !request.PackagesOnly && spec.Tools is not null; + + if (!runPackages && !runTools) + { + return new PowerForgeReleaseResult + { + Success = false, + ConfigPath = configPath, + ErrorMessage = "Release config does not enable any selected Packages or Tools sections." + }; + } + + var result = new PowerForgeReleaseResult + { + Success = true, + ConfigPath = configPath + }; + + if (runPackages) + { + var packageRequest = new ProjectBuildHostRequest + { + ConfigPath = configPath, + ExecuteBuild = !request.PlanOnly && !request.ValidateOnly, + PlanOnly = request.PlanOnly || request.ValidateOnly ? true : null, + PublishNuget = request.PublishNuget, + PublishGitHub = request.PublishProjectGitHub + }; + + var packages = _executePackages(packageRequest, spec.Packages!, configPath); + result.Packages = packages; + if (!packages.Success) + { + result.Success = false; + result.ErrorMessage = packages.ErrorMessage ?? "Package release workflow failed."; + return result; + } + } + + if (runTools) + { + var toolPlan = _planTools(spec.Tools!, configPath, request); + result.ToolPlan = toolPlan; + + if (!request.PlanOnly && !request.ValidateOnly) + { + var tools = _runTools(toolPlan); + result.Tools = tools; + if (!tools.Success) + { + result.Success = false; + result.ErrorMessage = tools.ErrorMessage ?? "Tool release workflow failed."; + return result; + } + + var publishToolGitHub = request.PublishToolGitHub ?? spec.Tools!.GitHub.Publish; + if (publishToolGitHub) + { + var releases = PublishToolGitHubReleases(spec, configDirectory, toolPlan, tools); + result.ToolGitHubReleases = releases; + var failures = releases.Where(entry => !entry.Success).ToArray(); + if (failures.Length > 0) + { + result.Success = false; + result.ErrorMessage = failures[0].ErrorMessage ?? "Tool GitHub release publishing failed."; + return result; + } + } + } + } + + return result; + } + + private PowerForgeToolGitHubReleaseResult[] PublishToolGitHubReleases( + PowerForgeReleaseSpec spec, + string configDirectory, + PowerForgeToolReleasePlan plan, + PowerForgeToolReleaseResult result) + { + var gitHub = spec.Tools?.GitHub ?? new PowerForgeToolReleaseGitHubOptions(); + var owner = string.IsNullOrWhiteSpace(gitHub.Owner) + ? spec.Packages?.GitHubUsername + : gitHub.Owner!.Trim(); + var repository = string.IsNullOrWhiteSpace(gitHub.Repository) + ? spec.Packages?.GitHubRepositoryName + : gitHub.Repository!.Trim(); + var token = ProjectBuildSupportService.ResolveSecret( + gitHub.Token, + gitHub.TokenFilePath, + gitHub.TokenEnvName, + configDirectory); + + if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repository)) + { + return new[] + { + new PowerForgeToolGitHubReleaseResult + { + Success = false, + ErrorMessage = "Tool GitHub publishing requires Owner and Repository." + } + }; + } + + if (string.IsNullOrWhiteSpace(token)) + { + return new[] + { + new PowerForgeToolGitHubReleaseResult + { + Success = false, + ErrorMessage = "Tool GitHub publishing requires a token." + } + }; + } + + var tagTemplate = string.IsNullOrWhiteSpace(gitHub.TagTemplate) + ? "{Target}-v{Version}" + : gitHub.TagTemplate!; + var releaseNameTemplate = string.IsNullOrWhiteSpace(gitHub.ReleaseNameTemplate) + ? "{Target} {Version}" + : gitHub.ReleaseNameTemplate!; + + var artefactGroups = result.Artefacts + .Where(entry => !string.IsNullOrWhiteSpace(entry.ZipPath)) + .GroupBy(entry => (Target: entry.Target, Version: entry.Version)) + .ToArray(); + + if (artefactGroups.Length == 0) + { + return new[] + { + new PowerForgeToolGitHubReleaseResult + { + Success = false, + ErrorMessage = "Tool GitHub publishing requires zip assets, but no ZipPath values were produced." + } + }; + } + + var results = new List(); + foreach (var group in artefactGroups) + { + var assets = group + .Select(entry => entry.ZipPath!) + .Where(File.Exists) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (assets.Length == 0) + { + results.Add(new PowerForgeToolGitHubReleaseResult + { + Target = group.Key.Target, + Version = group.Key.Version, + Success = false, + ErrorMessage = $"No zip assets found on disk for tool target '{group.Key.Target}'." + }); + continue; + } + + var tagName = ApplyGitHubTemplate(tagTemplate, group.Key.Target, group.Key.Version, repository!); + var releaseName = ApplyGitHubTemplate(releaseNameTemplate, group.Key.Target, group.Key.Version, repository!); + + try + { + var publishResult = _publishGitHubRelease(new GitHubReleasePublishRequest + { + Owner = owner!, + Repository = repository!, + Token = token!, + TagName = tagName, + ReleaseName = releaseName, + GenerateReleaseNotes = gitHub.GenerateReleaseNotes, + IsPreRelease = gitHub.IsPreRelease, + ReuseExistingReleaseOnConflict = true, + AssetFilePaths = assets + }); + + results.Add(new PowerForgeToolGitHubReleaseResult + { + Target = group.Key.Target, + Version = group.Key.Version, + TagName = tagName, + ReleaseName = releaseName, + AssetPaths = assets, + Success = publishResult.Succeeded, + ReleaseUrl = publishResult.HtmlUrl, + ReusedExistingRelease = publishResult.ReusedExistingRelease, + ErrorMessage = publishResult.Succeeded ? null : "GitHub release publish failed.", + SkippedExistingAssets = publishResult.SkippedExistingAssets?.ToArray() ?? Array.Empty() + }); + } + catch (Exception ex) + { + results.Add(new PowerForgeToolGitHubReleaseResult + { + Target = group.Key.Target, + Version = group.Key.Version, + TagName = tagName, + ReleaseName = releaseName, + AssetPaths = assets, + Success = false, + ErrorMessage = ex.Message + }); + } + } + + return results.ToArray(); + } + + private static string ApplyGitHubTemplate(string template, string target, string version, string repository) + { + var now = DateTime.Now; + var utcNow = DateTime.UtcNow; + return template + .Replace("{Target}", target) + .Replace("{Project}", target) + .Replace("{Version}", version) + .Replace("{Repo}", repository) + .Replace("{Repository}", repository) + .Replace("{Date}", now.ToString("yyyy.MM.dd")) + .Replace("{UtcDate}", utcNow.ToString("yyyy.MM.dd")) + .Replace("{DateTime}", now.ToString("yyyyMMddHHmmss")) + .Replace("{UtcDateTime}", utcNow.ToString("yyyyMMddHHmmss")) + .Replace("{Timestamp}", now.ToString("yyyyMMddHHmmss")) + .Replace("{UtcTimestamp}", utcNow.ToString("yyyyMMddHHmmss")); + } +} diff --git a/PowerForge/Services/PowerForgeToolReleaseService.cs b/PowerForge/Services/PowerForgeToolReleaseService.cs new file mode 100644 index 00000000..304bdf53 --- /dev/null +++ b/PowerForge/Services/PowerForgeToolReleaseService.cs @@ -0,0 +1,653 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Text; +using System.Text.Json; + +namespace PowerForge; + +/// +/// Builds downloadable runtime-specific tool executables from a typed configuration. +/// +internal sealed class PowerForgeToolReleaseService +{ + private readonly ILogger _logger; + private readonly Func _runProcess; + + /// + /// Creates a new tool release service. + /// + public PowerForgeToolReleaseService(ILogger logger) + : this(logger, RunProcess) + { + } + + internal PowerForgeToolReleaseService( + ILogger logger, + Func runProcess) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _runProcess = runProcess ?? throw new ArgumentNullException(nameof(runProcess)); + } + + /// + /// Plans tool outputs without executing publish commands. + /// + public PowerForgeToolReleasePlan Plan(PowerForgeToolReleaseSpec spec, string? configPath, PowerForgeReleaseRequest? request = null) + { + if (spec is null) + throw new ArgumentNullException(nameof(spec)); + + var configDir = string.IsNullOrWhiteSpace(configPath) + ? Directory.GetCurrentDirectory() + : Path.GetDirectoryName(Path.GetFullPath(configPath)) ?? Directory.GetCurrentDirectory(); + + var projectRoot = string.IsNullOrWhiteSpace(spec.ProjectRoot) + ? configDir + : ResolvePath(configDir, spec.ProjectRoot!); + + if (!Directory.Exists(projectRoot)) + throw new DirectoryNotFoundException($"Tool release ProjectRoot not found: {projectRoot}"); + + var configuration = string.IsNullOrWhiteSpace(spec.Configuration) ? "Release" : spec.Configuration.Trim(); + var selectedTargets = NormalizeStrings(request?.Targets); + var overrideRuntimes = NormalizeStrings(request?.Runtimes); + var overrideFrameworks = NormalizeStrings(request?.Frameworks); + var overrideFlavors = NormalizeFlavors(request?.Flavors); + + var plans = new List(); + foreach (var target in spec.Targets ?? Array.Empty()) + { + if (target is null) + continue; + + var name = (target.Name ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Tools.Targets[].Name is required.", nameof(spec)); + + if (selectedTargets.Length > 0 && !selectedTargets.Contains(name, StringComparer.OrdinalIgnoreCase)) + continue; + + var projectPath = ResolvePath(projectRoot, target.ProjectPath ?? string.Empty); + if (string.IsNullOrWhiteSpace(target.ProjectPath)) + throw new ArgumentException($"Tools target '{name}' requires ProjectPath.", nameof(spec)); + if (!File.Exists(projectPath)) + throw new FileNotFoundException($"Tool release project not found for target '{name}': {projectPath}", projectPath); + + if (!CsprojVersionEditor.TryGetVersion(projectPath, out var version) || string.IsNullOrWhiteSpace(version)) + throw new InvalidOperationException($"Unable to resolve Version/VersionPrefix from '{projectPath}'."); + + var outputName = string.IsNullOrWhiteSpace(target.OutputName) ? name : target.OutputName.Trim(); + var commandAlias = string.IsNullOrWhiteSpace(target.CommandAlias) ? null : target.CommandAlias!.Trim(); + + var frameworks = overrideFrameworks.Length > 0 + ? overrideFrameworks + : NormalizeStrings(target.Frameworks); + if (frameworks.Length == 0) + throw new ArgumentException($"Tools target '{name}' requires at least one framework.", nameof(spec)); + + var runtimes = overrideRuntimes.Length > 0 + ? overrideRuntimes + : NormalizeStrings(target.Runtimes); + if (runtimes.Length == 0) + throw new ArgumentException($"Tools target '{name}' requires at least one runtime.", nameof(spec)); + + var flavors = overrideFlavors.Length > 0 + ? overrideFlavors + : NormalizeFlavors(target.Flavors); + if (flavors.Length == 0) + flavors = new[] { target.Flavor }; + + var artifactRoot = string.IsNullOrWhiteSpace(target.ArtifactRootPath) + ? ResolvePath(projectRoot, Path.Combine("Artifacts", outputName)) + : ResolvePath(projectRoot, target.ArtifactRootPath!); + + var msbuildProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in target.MsBuildProperties ?? new Dictionary()) + { + if (string.IsNullOrWhiteSpace(kv.Key)) + continue; + + msbuildProperties[kv.Key.Trim()] = kv.Value ?? string.Empty; + } + + var combinations = new List(); + foreach (var framework in frameworks) + { + foreach (var runtime in runtimes) + { + foreach (var flavor in flavors) + { + var tokens = BuildTokens(name, outputName, version, runtime, framework, flavor, configuration); + var defaultOutput = Path.Combine(artifactRoot, "{rid}", "{framework}", "{flavor}"); + var outputTemplate = string.IsNullOrWhiteSpace(target.OutputPath) + ? defaultOutput + : target.OutputPath!; + + var outputPath = ResolvePath(projectRoot, ApplyTemplate(outputTemplate, tokens)); + var zipPath = ResolveZipPath(projectRoot, target, outputPath, tokens); + + combinations.Add(new PowerForgeToolReleaseCombinationPlan + { + Runtime = runtime, + Framework = framework, + Flavor = flavor, + OutputPath = outputPath, + ZipPath = target.Zip ? zipPath : null + }); + } + } + } + + plans.Add(new PowerForgeToolReleaseTargetPlan + { + Name = name, + ProjectPath = projectPath, + OutputName = outputName, + CommandAlias = commandAlias, + Version = version, + ArtifactRootPath = artifactRoot, + UseStaging = target.UseStaging, + ClearOutput = target.ClearOutput, + Zip = target.Zip, + KeepSymbols = target.KeepSymbols, + KeepDocs = target.KeepDocs, + CreateCommandAliasOnUnix = target.CreateCommandAliasOnUnix, + MsBuildProperties = msbuildProperties, + Combinations = combinations + .OrderBy(c => c.Framework, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.Runtime, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.Flavor.ToString(), StringComparer.OrdinalIgnoreCase) + .ToArray() + }); + } + + if (selectedTargets.Length > 0) + { + var missing = selectedTargets + .Where(selected => plans.All(plan => !string.Equals(plan.Name, selected, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + if (missing.Length > 0) + throw new ArgumentException($"Unknown tool target(s): {string.Join(", ", missing)}", nameof(request)); + } + + if (plans.Count == 0) + throw new InvalidOperationException("No tool release targets were selected."); + + return new PowerForgeToolReleasePlan + { + ProjectRoot = projectRoot, + Configuration = configuration, + Targets = plans.ToArray() + }; + } + + /// + /// Executes the planned tool releases. + /// + public PowerForgeToolReleaseResult Run(PowerForgeToolReleasePlan plan) + { + if (plan is null) + throw new ArgumentNullException(nameof(plan)); + + var artefacts = new List(); + var manifests = new List(); + + try + { + foreach (var target in plan.Targets ?? Array.Empty()) + { + var targetArtefacts = new List(); + foreach (var combination in target.Combinations ?? Array.Empty()) + { + targetArtefacts.Add(PublishOne(plan, target, combination)); + } + + artefacts.AddRange(targetArtefacts); + manifests.Add(WriteManifest(target, targetArtefacts)); + } + + return new PowerForgeToolReleaseResult + { + Success = true, + Artefacts = artefacts.ToArray(), + ManifestPaths = manifests.ToArray() + }; + } + catch (Exception ex) + { + _logger.Error(ex.Message); + if (_logger.IsVerbose) + _logger.Verbose(ex.ToString()); + + return new PowerForgeToolReleaseResult + { + Success = false, + ErrorMessage = ex.Message, + Artefacts = artefacts.ToArray(), + ManifestPaths = manifests.ToArray() + }; + } + } + + private PowerForgeToolReleaseArtifactResult PublishOne( + PowerForgeToolReleasePlan plan, + PowerForgeToolReleaseTargetPlan target, + PowerForgeToolReleaseCombinationPlan combination) + { + Directory.CreateDirectory(target.ArtifactRootPath); + + var publishDir = combination.OutputPath; + string? stagingDir = null; + if (target.UseStaging) + { + stagingDir = Path.Combine(Path.GetTempPath(), $"PowerForge.ToolRelease.{Guid.NewGuid():N}"); + publishDir = stagingDir; + Directory.CreateDirectory(publishDir); + } + + try + { + if (target.ClearOutput && !target.UseStaging) + ClearDirectory(combination.OutputPath); + + Directory.CreateDirectory(publishDir); + ExecutePublish(plan, target, combination, publishDir); + ApplyCleanup(publishDir, target); + var executablePath = RenameMainExecutable(target, publishDir, combination.Runtime); + + if (target.ClearOutput && target.UseStaging) + ClearDirectory(combination.OutputPath); + + if (target.UseStaging) + CopyDirectoryContents(publishDir, combination.OutputPath); + + var finalExecutablePath = target.UseStaging + ? Path.Combine(combination.OutputPath, Path.GetFileName(executablePath)) + : executablePath; + + string? aliasPath = null; + if (!combination.Runtime.StartsWith("win-", StringComparison.OrdinalIgnoreCase) + && target.CreateCommandAliasOnUnix + && !string.IsNullOrWhiteSpace(target.CommandAlias)) + { + aliasPath = Path.Combine(combination.OutputPath, target.CommandAlias!); + if (!string.Equals(aliasPath, finalExecutablePath, StringComparison.OrdinalIgnoreCase)) + File.Copy(finalExecutablePath, aliasPath, overwrite: true); + } + + string? zipPath = null; + if (!string.IsNullOrWhiteSpace(combination.ZipPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(combination.ZipPath!)!); + if (File.Exists(combination.ZipPath!)) + File.Delete(combination.ZipPath!); + ZipFile.CreateFromDirectory(combination.OutputPath, combination.ZipPath!); + zipPath = combination.ZipPath; + } + + var (files, totalBytes) = SummarizeDirectory(combination.OutputPath); + return new PowerForgeToolReleaseArtifactResult + { + Target = target.Name, + Version = target.Version, + OutputName = target.OutputName, + Runtime = combination.Runtime, + Framework = combination.Framework, + Flavor = combination.Flavor, + OutputPath = combination.OutputPath, + ExecutablePath = finalExecutablePath, + CommandAliasPath = aliasPath, + ZipPath = zipPath, + Files = files, + TotalBytes = totalBytes + }; + } + finally + { + if (!string.IsNullOrWhiteSpace(stagingDir) && Directory.Exists(stagingDir)) + { + try + { + Directory.Delete(stagingDir, recursive: true); + } + catch + { + // best effort + } + } + } + } + + private void ExecutePublish( + PowerForgeToolReleasePlan plan, + PowerForgeToolReleaseTargetPlan target, + PowerForgeToolReleaseCombinationPlan combination, + string publishDir) + { + var projectName = Path.GetFileNameWithoutExtension(target.ProjectPath) ?? target.Name; + _logger.Info($"Publishing {target.Name} {target.Version} ({combination.Framework}, {combination.Runtime}, {combination.Flavor})"); + + var psi = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = Path.GetDirectoryName(target.ProjectPath) ?? plan.ProjectRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + ProcessStartInfoEncoding.TryApplyUtf8(psi); + + var args = new List + { + "publish", + Quote(target.ProjectPath), + "-c", + Quote(plan.Configuration), + "-f", + Quote(combination.Framework), + "-r", + Quote(combination.Runtime) + }; + + var (selfContained, singleFile, compress, selfExtract) = ResolveFlavor(combination.Flavor); + args.Add($"--self-contained:{selfContained.ToString().ToLowerInvariant()}"); + args.Add($"/p:PublishSingleFile={singleFile.ToString().ToLowerInvariant()}"); + args.Add("/p:PublishReadyToRun=false"); + args.Add("/p:PublishTrimmed=false"); + args.Add($"/p:IncludeAllContentForSelfExtract={selfExtract.ToString().ToLowerInvariant()}"); + args.Add($"/p:IncludeNativeLibrariesForSelfExtract={selfExtract.ToString().ToLowerInvariant()}"); + args.Add($"/p:EnableCompressionInSingleFile={compress.ToString().ToLowerInvariant()}"); + args.Add("/p:EnableSingleFileAnalyzer=false"); + args.Add("/p:DebugType=None"); + args.Add("/p:DebugSymbols=false"); + args.Add("/p:GenerateDocumentationFile=false"); + args.Add("/p:CopyDocumentationFiles=false"); + args.Add("/p:ExcludeSymbolsFromSingleFile=true"); + args.Add("/p:ErrorOnDuplicatePublishOutputFiles=false"); + args.Add("/p:UseAppHost=true"); + args.Add($"/p:PublishDir={Quote(publishDir)}"); + + foreach (var kv in target.MsBuildProperties) + args.Add($"/p:{kv.Key}={kv.Value}"); + + psi.Arguments = string.Join(" ", args); + + var processResult = _runProcess(psi); + if (processResult.ExitCode != 0) + { + throw new InvalidOperationException( + $"dotnet publish failed for '{target.Name}' ({projectName}, {combination.Runtime}, {combination.Framework}, {combination.Flavor}). " + + $"{TrimForMessage(processResult.StdErr, processResult.StdOut)}"); + } + } + + private void ApplyCleanup(string publishDir, PowerForgeToolReleaseTargetPlan target) + { + if (!target.KeepSymbols) + { + foreach (var file in Directory.EnumerateFiles(publishDir, "*.pdb", SearchOption.AllDirectories)) + { + try { File.Delete(file); } catch { } + } + } + + if (!target.KeepDocs) + { + foreach (var file in Directory.EnumerateFiles(publishDir, "*", SearchOption.AllDirectories)) + { + var extension = Path.GetExtension(file); + if (!extension.Equals(".xml", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase)) + continue; + + try { File.Delete(file); } catch { } + } + } + } + + private static string RenameMainExecutable( + PowerForgeToolReleaseTargetPlan target, + string publishDir, + string runtime) + { + var isWindows = runtime.StartsWith("win-", StringComparison.OrdinalIgnoreCase); + var candidateName = Path.GetFileNameWithoutExtension(target.ProjectPath) ?? target.Name; + var sourceName = isWindows ? $"{candidateName}.exe" : candidateName; + var sourcePath = Path.Combine(publishDir, sourceName); + if (!File.Exists(sourcePath)) + { + sourcePath = FindLargestCandidate(publishDir, isWindows) + ?? throw new FileNotFoundException($"Main executable not found in publish output: {publishDir}"); + } + + var desiredName = isWindows ? $"{target.OutputName}.exe" : target.OutputName; + var desiredPath = Path.Combine(publishDir, desiredName); + if (!string.Equals(sourcePath, desiredPath, StringComparison.OrdinalIgnoreCase)) + { + if (File.Exists(desiredPath)) + File.Delete(desiredPath); + File.Move(sourcePath, desiredPath); + } + + return desiredPath; + } + + private static string? FindLargestCandidate(string publishDir, bool isWindows) + { + var files = Directory.EnumerateFiles(publishDir, "*", SearchOption.TopDirectoryOnly) + .Select(path => new FileInfo(path)) + .Where(file => isWindows + ? string.Equals(file.Extension, ".exe", StringComparison.OrdinalIgnoreCase) + : string.IsNullOrWhiteSpace(file.Extension)) + .OrderByDescending(file => file.Length) + .ToArray(); + + return files.FirstOrDefault()?.FullName; + } + + private static (bool SelfContained, bool SingleFile, bool Compress, bool SelfExtract) ResolveFlavor(PowerForgeToolReleaseFlavor flavor) + => flavor switch + { + PowerForgeToolReleaseFlavor.SingleContained => (true, true, true, true), + PowerForgeToolReleaseFlavor.SingleFx => (false, true, true, false), + PowerForgeToolReleaseFlavor.Portable => (true, false, false, false), + PowerForgeToolReleaseFlavor.Fx => (false, false, false, false), + _ => throw new ArgumentOutOfRangeException(nameof(flavor), flavor, "Unsupported tool release flavor.") + }; + + private static string WriteManifest(PowerForgeToolReleaseTargetPlan target, IReadOnlyList artefacts) + { + var manifestPath = Path.Combine(target.ArtifactRootPath, "release-manifest.json"); + Directory.CreateDirectory(target.ArtifactRootPath); + + var manifest = new PowerForgeToolReleaseManifest + { + Target = target.Name, + Version = target.Version, + OutputName = target.OutputName, + Artefacts = artefacts.ToArray() + }; + + var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine; + File.WriteAllText(manifestPath, json, new UTF8Encoding(false)); + return manifestPath; + } + + private static string ResolveZipPath( + string projectRoot, + PowerForgeToolReleaseTarget target, + string outputPath, + IReadOnlyDictionary tokens) + { + if (!target.Zip) + return string.Empty; + + if (!string.IsNullOrWhiteSpace(target.ZipPath)) + return ResolvePath(projectRoot, ApplyTemplate(target.ZipPath!, tokens)); + + var zipNameTemplate = string.IsNullOrWhiteSpace(target.ZipNameTemplate) + ? "{outputName}-{version}-{framework}-{rid}-{flavor}.zip" + : target.ZipNameTemplate!; + var zipName = ApplyTemplate(zipNameTemplate, tokens); + if (!zipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + zipName += ".zip"; + + return Path.Combine(Path.GetDirectoryName(outputPath)!, zipName); + } + + private static Dictionary BuildTokens( + string target, + string outputName, + string version, + string runtime, + string framework, + PowerForgeToolReleaseFlavor flavor, + string configuration) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["target"] = target, + ["outputName"] = outputName, + ["version"] = version, + ["rid"] = runtime, + ["runtime"] = runtime, + ["framework"] = framework, + ["flavor"] = flavor.ToString(), + ["configuration"] = configuration + }; + } + + private static string ApplyTemplate(string template, IReadOnlyDictionary tokens) + { + var value = template ?? string.Empty; + foreach (var kv in tokens) + value = value.Replace("{" + kv.Key + "}", kv.Value ?? string.Empty); + return value; + } + + private static string[] NormalizeStrings(IEnumerable? values) + => (values ?? Array.Empty()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static PowerForgeToolReleaseFlavor[] NormalizeFlavors(IEnumerable? values) + => (values ?? Array.Empty()) + .Distinct() + .ToArray(); + + private static string ResolvePath(string basePath, string value) + { + var trimmed = (value ?? string.Empty).Trim().Trim('"'); + if (string.IsNullOrWhiteSpace(trimmed)) + throw new ArgumentException("Path value is required.", nameof(value)); + + return Path.GetFullPath(Path.IsPathRooted(trimmed) ? trimmed : Path.Combine(basePath, trimmed)); + } + + private static void ClearDirectory(string path) + { + if (!Directory.Exists(path)) + return; + + foreach (var entry in Directory.GetFileSystemEntries(path)) + { + try + { + if (Directory.Exists(entry)) + Directory.Delete(entry, recursive: true); + else + File.Delete(entry); + } + catch + { + // best effort + } + } + } + + private static void CopyDirectoryContents(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (var directory in Directory.GetDirectories(source, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, directory); + Directory.CreateDirectory(Path.Combine(destination, relative)); + } + + foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, file); + var targetPath = Path.Combine(destination, relative); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Copy(file, targetPath, overwrite: true); + } + } + + private static (int Files, long TotalBytes) SummarizeDirectory(string path) + { + var files = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Select(file => new FileInfo(file)) + .ToArray(); + + long total = 0; + foreach (var file in files) + total += file.Length; + + return (files.Length, total); + } + + private static ProcessExecutionResult RunProcess(ProcessStartInfo startInfo) + { + using var process = Process.Start(startInfo); + if (process is null) + return new ProcessExecutionResult(1, string.Empty, "Failed to start process."); + + var stdOutTask = process.StandardOutput.ReadToEndAsync(); + var stdErrTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + return new ProcessExecutionResult( + process.ExitCode, + stdOutTask.GetAwaiter().GetResult(), + stdErrTask.GetAwaiter().GetResult()); + } + + private static string TrimForMessage(string? stdErr, string? stdOut) + { + var combined = string.Join( + Environment.NewLine, + new[] { stdErr?.Trim(), stdOut?.Trim() }.Where(text => !string.IsNullOrWhiteSpace(text))); + if (combined.Length <= 3000) + return combined; + + return combined.Substring(0, 3000) + "..."; + } + + private static string Quote(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "\"\""; + + return value.Contains(" ", StringComparison.Ordinal) || value.Contains("\"", StringComparison.Ordinal) + ? "\"" + value.Replace("\"", "\\\"") + "\"" + : value; + } + + internal struct ProcessExecutionResult + { + public ProcessExecutionResult(int exitCode, string stdOut, string stdErr) + { + ExitCode = exitCode; + StdOut = stdOut ?? string.Empty; + StdErr = stdErr ?? string.Empty; + } + + public int ExitCode { get; } + + public string StdOut { get; } + + public string StdErr { get; } + } +} diff --git a/PowerForge/Services/RunnerHousekeepingService.cs b/PowerForge/Services/RunnerHousekeepingService.cs index 8a7e0696..1e3faa47 100644 --- a/PowerForge/Services/RunnerHousekeepingService.cs +++ b/PowerForge/Services/RunnerHousekeepingService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -273,7 +274,7 @@ private RunnerHousekeepingStepResult DeleteFilesOlderThan(string id, string titl .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .ToArray(); - return DeleteTargets(id, title, targets, dryRun, allowSudo: false, isDirectory: false); + return DeleteTargets(id, title, targets, dryRun, allowSudo: false, isDirectory: false, allowedRootPath: rootPath); } private RunnerHousekeepingStepResult DeleteDirectoryContents(string id, string title, string? rootPath, bool dryRun) @@ -285,7 +286,7 @@ private RunnerHousekeepingStepResult DeleteDirectoryContents(string id, string t .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .ToArray(); - return DeleteTargets(id, title, targets, dryRun, allowSudo: false, isDirectory: null); + return DeleteTargets(id, title, targets, dryRun, allowSudo: false, isDirectory: null, allowedRootPath: rootPath); } private RunnerHousekeepingStepResult DeleteDirectoriesOlderThan(string id, string title, string? rootPath, int retentionDays, bool dryRun, bool allowSudo) @@ -299,10 +300,10 @@ private RunnerHousekeepingStepResult DeleteDirectoriesOlderThan(string id, strin .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .ToArray(); - return DeleteTargets(id, title, targets, dryRun, allowSudo, isDirectory: true); + return DeleteTargets(id, title, targets, dryRun, allowSudo, isDirectory: true, allowedRootPath: rootPath); } - private RunnerHousekeepingStepResult DeleteTargets(string id, string title, string[] targets, bool dryRun, bool allowSudo, bool? isDirectory) + private RunnerHousekeepingStepResult DeleteTargets(string id, string title, string[] targets, bool dryRun, bool allowSudo, bool? isDirectory, string? allowedRootPath) { if (targets.Length == 0) return SkippedStep(id, title, "Nothing to clean."); @@ -321,23 +322,41 @@ private RunnerHousekeepingStepResult DeleteTargets(string id, string title, stri }; } + var deleted = new List(targets.Length); + var failures = new List(); + foreach (var target in targets) { - DeleteTarget(target, allowSudo, isDirectory); + try + { + DeleteTarget(target, allowSudo, isDirectory, allowedRootPath); + deleted.Add(target); + } + catch (Exception ex) + { + failures.Add($"{target}: {ex.Message}"); + } } - _logger.Info($"{title}: deleted {targets.Length} item(s)."); + if (failures.Count == 0) + _logger.Info($"{title}: deleted {deleted.Count} item(s)."); + else + _logger.Warn($"{title}: deleted {deleted.Count} item(s), failed {failures.Count} item(s)."); + return new RunnerHousekeepingStepResult { Id = id, Title = title, - EntriesAffected = targets.Length, - Message = $"Deleted {targets.Length} item(s).", + Success = failures.Count == 0, + EntriesAffected = deleted.Count, + Message = failures.Count == 0 + ? $"Deleted {deleted.Count} item(s)." + : $"Deleted {deleted.Count} item(s); failed {failures.Count} item(s): {string.Join(" | ", failures)}", Targets = targets }; } - private void DeleteTarget(string target, bool allowSudo, bool? isDirectory) + private void DeleteTarget(string target, bool allowSudo, bool? isDirectory, string? allowedRootPath) { try { @@ -352,7 +371,7 @@ private void DeleteTarget(string target, bool allowSudo, bool? isDirectory) } catch when (allowSudo && CanUseSudo() && (isDirectory == true || Directory.Exists(target))) { - RunSudoDelete(target); + RunSudoDelete(target, allowedRootPath); } } @@ -428,8 +447,8 @@ private static long GetFreeBytes(string path) private static long ToGiBBytes(int gibibytes) => gibibytes <= 0 ? 0 : (long)gibibytes * 1024L * 1024L * 1024L; - private static long FormatGiB(long bytes) - => bytes <= 0 ? 0 : bytes / 1024L / 1024L / 1024L; + private static string FormatGiB(long bytes) + => (bytes <= 0 ? 0d : bytes / 1024d / 1024d / 1024d).ToString("N1", CultureInfo.InvariantCulture); private static string? ResolvePathOrNull(string? value) { @@ -471,13 +490,36 @@ private static bool CommandExists(string fileName) private static bool CanUseSudo() => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && CommandExists("sudo"); - private void RunSudoDelete(string target) + private void RunSudoDelete(string target, string? allowedRootPath) { + EnsureDeleteTargetWithinRoot(target, allowedRootPath); var result = RunProcess("sudo", new[] { "rm", "-rf", target }, workingDirectory: Path.GetDirectoryName(target) ?? Environment.CurrentDirectory); if (result.ExitCode != 0) throw new IOException(string.IsNullOrWhiteSpace(result.StdErr) ? $"sudo rm -rf failed for '{target}'." : result.StdErr.Trim()); } + private static void EnsureDeleteTargetWithinRoot(string target, string? allowedRootPath) + { + var root = ResolvePathOrNull(allowedRootPath); + if (string.IsNullOrWhiteSpace(root)) + throw new InvalidOperationException($"Refusing sudo delete for '{target}' because no safe root was supplied."); + + var fullRoot = AppendDirectorySeparator(root!); + var fullTarget = Path.GetFullPath(target); + if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Refusing sudo delete for '{target}' because it is outside '{root}'."); + } + + private static string AppendDirectorySeparator(string path) + { + if (string.IsNullOrEmpty(path)) + return Path.DirectorySeparatorChar.ToString(); + + return path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar) + ? path + : path + Path.DirectorySeparatorChar; + } + private static (int ExitCode, string StdOut, string StdErr) RunProcess(string fileName, IReadOnlyList arguments, string workingDirectory) { var psi = new ProcessStartInfo diff --git a/README.MD b/README.MD index 707176a3..b61f1cfb 100644 --- a/README.MD +++ b/README.MD @@ -210,25 +210,28 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} ``` -For release/build packaging, this repo now also ships a standard project-build entrypoint: +For release/build packaging, this repo now uses one unified release entrypoint: ```powershell .\Build\Build-Project.ps1 .\Build\Build-Project.ps1 -Plan .\Build\Build-Project.ps1 -PublishNuget $true -PublishGitHub $true +.\Build\Build-Project.ps1 -ToolsOnly -PublishToolGitHub $true ``` -For downloadable CLI binaries, use the tool release builders: +`Build\release.json` is the source of truth for both package releases and downloadable tool binaries. + +For targeted tool-only runs, the convenience wrappers still exist: ```powershell # Build PowerForge.exe / PowerForge for one runtime -.\Build\Build-PowerForge.ps1 -Tool PowerForge -Runtime win-x64 -Framework net8.0 -Flavor SingleFx -Zip +.\Build\Build-PowerForge.ps1 -Tool PowerForge -Runtime win-x64 -Framework net10.0 -Flavor SingleContained # Build PowerForgeWeb.exe / PowerForgeWeb -.\Build\Build-PowerForgeWeb.ps1 -Runtime win-x64 -Framework net8.0 -Flavor SingleFx -Zip +.\Build\Build-PowerForgeWeb.ps1 -Runtime win-x64 -Framework net10.0 -Flavor SingleContained # Optional: publish the generated zip assets to GitHub releases -.\Build\Build-PowerForge.ps1 -Tool All -Runtime win-x64,linux-x64,osx-arm64 -Framework net8.0 -Flavor SingleFx -Zip -PublishGitHub +.\Build\Build-PowerForge.ps1 -Tool All -Runtime win-x64,linux-x64,osx-arm64 -Framework net10.0 -Flavor SingleContained -PublishGitHub ``` Introduced in **1.0.0** a new way to build PowerShell module based on DSL language. diff --git a/Schemas/powerforge.release.schema.json b/Schemas/powerforge.release.schema.json new file mode 100644 index 00000000..fe9e1bd6 --- /dev/null +++ b/Schemas/powerforge.release.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PowerForge Unified Release Configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "SchemaVersion": { "type": "integer", "minimum": 1 }, + "Packages": { "$ref": "project.build.schema.json" }, + "Tools": { + "type": "object", + "additionalProperties": false, + "properties": { + "ProjectRoot": { "type": [ "string", "null" ] }, + "Configuration": { "type": "string", "enum": [ "Release", "Debug" ] }, + "Targets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ "Name", "ProjectPath", "OutputName", "Runtimes", "Frameworks" ], + "properties": { + "Name": { "type": "string" }, + "ProjectPath": { "type": "string" }, + "OutputName": { "type": "string" }, + "CommandAlias": { "type": [ "string", "null" ] }, + "Runtimes": { "type": "array", "items": { "type": "string" } }, + "Frameworks": { "type": "array", "items": { "type": "string" } }, + "Flavor": { "type": "string", "enum": [ "SingleContained", "SingleFx", "Portable", "Fx" ] }, + "Flavors": { + "type": "array", + "items": { "type": "string", "enum": [ "SingleContained", "SingleFx", "Portable", "Fx" ] } + }, + "ArtifactRootPath": { "type": [ "string", "null" ] }, + "OutputPath": { "type": [ "string", "null" ] }, + "UseStaging": { "type": "boolean" }, + "ClearOutput": { "type": "boolean" }, + "Zip": { "type": "boolean" }, + "ZipPath": { "type": [ "string", "null" ] }, + "ZipNameTemplate": { "type": [ "string", "null" ] }, + "KeepSymbols": { "type": "boolean" }, + "KeepDocs": { "type": "boolean" }, + "CreateCommandAliasOnUnix": { "type": "boolean" }, + "MsBuildProperties": { + "type": [ "object", "null" ], + "additionalProperties": { "type": "string" } + } + } + } + }, + "GitHub": { + "type": "object", + "additionalProperties": false, + "properties": { + "Publish": { "type": "boolean" }, + "Owner": { "type": [ "string", "null" ] }, + "Repository": { "type": [ "string", "null" ] }, + "Token": { "type": [ "string", "null" ] }, + "TokenFilePath": { "type": [ "string", "null" ] }, + "TokenEnvName": { "type": [ "string", "null" ] }, + "GenerateReleaseNotes": { "type": "boolean" }, + "IsPreRelease": { "type": "boolean" }, + "TagTemplate": { "type": [ "string", "null" ] }, + "ReleaseNameTemplate": { "type": [ "string", "null" ] } + } + } + } + } + } +}