diff --git a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 new file mode 100644 index 00000000..707b2e69 --- /dev/null +++ b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 @@ -0,0 +1,159 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path +$project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" + +function Format-GiB { + param([long] $Bytes) + + if ($Bytes -le 0) { + return '0.0 GiB' + } + + return ('{0:N1} GiB' -f ($Bytes / 1GB)) +} + +function Write-MarkdownSummary { + param([string[]] $Lines) + + if ([string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { + return + } + + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ($Lines -join [Environment]::NewLine) +} + +function Resolve-ConfigPath { + $configPath = $env:INPUT_CONFIG_PATH + if ([string]::IsNullOrWhiteSpace($configPath)) { + $configPath = '.powerforge/github-housekeeping.json' + } + + if ([System.IO.Path]::IsPathRooted($configPath)) { + return [System.IO.Path]::GetFullPath($configPath) + } + + if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { + throw 'GITHUB_WORKSPACE is not set.' + } + + return [System.IO.Path]::GetFullPath((Join-Path $env:GITHUB_WORKSPACE $configPath)) +} + +function Write-HousekeepingSummary { + param([pscustomobject] $Envelope) + + if (-not $Envelope.result) { + return + } + + $result = $Envelope.result + $lines = @( + "### GitHub housekeeping", + "", + "- Mode: $(if ($result.dryRun) { 'dry-run' } else { 'apply' })", + "- Requested sections: $((@($result.requestedSections) -join ', '))", + "- Completed sections: $((@($result.completedSections) -join ', '))", + "- Failed sections: $((@($result.failedSections) -join ', '))", + "- Success: $(if ($Envelope.success) { 'yes' } else { 'no' })" + ) + + if ($result.message) { + $lines += "- Message: $($result.message)" + } + + if ($result.caches) { + $lines += '' + $lines += '#### Caches' + if ($result.caches.usageBefore) { + $lines += "- Usage before: $($result.caches.usageBefore.activeCachesCount) caches, $(Format-GiB ([long]$result.caches.usageBefore.activeCachesSizeInBytes))" + } + if ($result.caches.usageAfter) { + $lines += "- Usage after: $($result.caches.usageAfter.activeCachesCount) caches, $(Format-GiB ([long]$result.caches.usageAfter.activeCachesSizeInBytes))" + } + $lines += "- Planned deletes: $($result.caches.plannedDeletes) ($(Format-GiB ([long]$result.caches.plannedDeleteBytes)))" + $lines += "- Deleted: $($result.caches.deletedCaches) ($(Format-GiB ([long]$result.caches.deletedBytes)))" + $lines += "- Failed deletes: $($result.caches.failedDeletes)" + } + + if ($result.artifacts) { + $lines += '' + $lines += '#### Artifacts' + $lines += "- Planned deletes: $($result.artifacts.plannedDeletes) ($(Format-GiB ([long]$result.artifacts.plannedDeleteBytes)))" + $lines += "- Deleted: $($result.artifacts.deletedArtifacts) ($(Format-GiB ([long]$result.artifacts.deletedBytes)))" + $lines += "- Failed deletes: $($result.artifacts.failedDeletes)" + } + + if ($result.runner) { + $lines += '' + $lines += '#### Runner' + $lines += "- Free before: $(Format-GiB ([long]$result.runner.freeBytesBefore))" + $lines += "- Free after: $(Format-GiB ([long]$result.runner.freeBytesAfter))" + $lines += "- Aggressive cleanup: $(if ($result.runner.aggressiveApplied) { 'yes' } else { 'no' })" + } + + Write-Host ("GitHub housekeeping: requested={0}; completed={1}; failed={2}" -f ` + (@($result.requestedSections) -join ','), ` + (@($result.completedSections) -join ','), ` + (@($result.failedSections) -join ',')) + + Write-MarkdownSummary -Lines ($lines + '') +} + +$configPath = Resolve-ConfigPath +if (-not (Test-Path -LiteralPath $configPath)) { + throw "Housekeeping config not found: $configPath" +} + +$arguments = [System.Collections.Generic.List[string]]::new() +$arguments.AddRange(@( + 'run', '--project', $project, '-c', 'Release', '--no-build', '--', + 'github', 'housekeeping', + '--config', $configPath +)) + +if ($env:INPUT_APPLY -eq 'true') { + $null = $arguments.Add('--apply') +} else { + $null = $arguments.Add('--dry-run') +} + +if (-not [string]::IsNullOrWhiteSpace($env:POWERFORGE_GITHUB_TOKEN)) { + $null = $arguments.Add('--token') + $null = $arguments.Add($env:POWERFORGE_GITHUB_TOKEN) +} + +$null = $arguments.Add('--output') +$null = $arguments.Add('json') + +$rawOutput = (& dotnet $arguments 2>&1 | Out-String).Trim() +$exitCode = $LASTEXITCODE + +if ([string]::IsNullOrWhiteSpace($rawOutput)) { + if ($exitCode -ne 0) { + throw "PowerForge housekeeping failed with exit code $exitCode and produced no output." + } + + return +} + +try { + $envelope = $rawOutput | ConvertFrom-Json -Depth 30 +} catch { + Write-Host $rawOutput + throw +} + +Write-HousekeepingSummary -Envelope $envelope + +if (-not $envelope.success) { + Write-Host $rawOutput + if ($envelope.exitCode) { + exit [int]$envelope.exitCode + } + + exit 1 +} diff --git a/.github/actions/github-housekeeping/README.md b/.github/actions/github-housekeeping/README.md new file mode 100644 index 00000000..fb82c154 --- /dev/null +++ b/.github/actions/github-housekeeping/README.md @@ -0,0 +1,50 @@ +# PowerForge GitHub Housekeeping + +Reusable composite action that runs the config-driven `powerforge github housekeeping` command from `PowerForge.Cli`. + +## What it does + +- Loads housekeeping settings from a repo config file, typically `.powerforge/github-housekeeping.json` +- Runs artifact cleanup, cache cleanup, and optional runner cleanup from one C# entrypoint +- Writes a workflow summary with the requested sections plus before/after cleanup stats + +## Recommended usage + +Use the reusable workflow for the leanest repo wiring: + +```yaml +permissions: + contents: read + actions: write + +jobs: + housekeeping: + uses: EvotecIT/PSPublishModule/.github/workflows/reusable-github-housekeeping.yml@main + with: + config-path: ./.powerforge/github-housekeeping.json + secrets: inherit +``` + +## Direct action usage + +```yaml +permissions: + contents: read + actions: write + +jobs: + housekeeping: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EvotecIT/PSPublishModule/.github/actions/github-housekeeping@main + with: + config-path: ./.powerforge/github-housekeeping.json + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Notes + +- Cache and artifact deletion need `actions: write`. +- Set `apply: "false"` to preview without deleting anything. +- Hosted-runner repos should usually keep `runner.enabled` set to `false` in config. diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml new file mode 100644 index 00000000..5ba2daae --- /dev/null +++ b/.github/actions/github-housekeeping/action.yml @@ -0,0 +1,44 @@ +name: PowerForge GitHub Housekeeping +description: Run config-driven GitHub housekeeping using PowerForge.Cli. + +inputs: + config-path: + description: Path to the housekeeping config file inside the checked-out repository. + required: false + default: ".powerforge/github-housekeeping.json" + apply: + description: When true, apply deletions. When false, run in dry-run mode. + required: false + default: "true" + github-token: + description: Optional token override for remote GitHub cleanup. + required: false + default: "" + +runs: + using: composite + steps: + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: ${{ github.action_path }}/../../global.json + + - name: Build PowerForge CLI + shell: pwsh + run: | + $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path + $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" + dotnet build $project -c Release + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + + - name: Run GitHub housekeeping + shell: pwsh + run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + INPUT_CONFIG_PATH: ${{ inputs['config-path'] }} + INPUT_APPLY: ${{ inputs.apply }} + POWERFORGE_GITHUB_TOKEN: ${{ inputs['github-token'] != '' && inputs['github-token'] || github.token }} diff --git a/.github/workflows/github-housekeeping.yml b/.github/workflows/github-housekeeping.yml new file mode 100644 index 00000000..0f1b16be --- /dev/null +++ b/.github/workflows/github-housekeeping.yml @@ -0,0 +1,29 @@ +name: GitHub Housekeeping + +on: + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + inputs: + apply: + description: 'Apply deletions (true/false)' + required: false + default: 'true' + +permissions: + actions: write + contents: read + +concurrency: + group: github-housekeeping-${{ github.repository }} + cancel-in-progress: false + +jobs: + housekeeping: + uses: ./.github/workflows/reusable-github-housekeeping.yml + with: + config-path: ./.powerforge/github-housekeeping.json + apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply == 'true' || github.event_name != 'workflow_dispatch' }} + powerforge-ref: ${{ github.sha }} + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reusable-github-housekeeping.yml b/.github/workflows/reusable-github-housekeeping.yml new file mode 100644 index 00000000..d23217c7 --- /dev/null +++ b/.github/workflows/reusable-github-housekeeping.yml @@ -0,0 +1,48 @@ +name: Reusable GitHub Housekeeping + +on: + workflow_call: + inputs: + config-path: + description: Path to the housekeeping config file in the caller repository. + required: false + default: ".powerforge/github-housekeeping.json" + type: string + apply: + description: Whether the run should apply deletions. + required: false + default: true + type: boolean + powerforge-ref: + description: PSPublishModule ref used to resolve the shared housekeeping action. + required: false + default: "main" + type: string + secrets: + github-token: + required: false + +permissions: + actions: write + contents: read + +jobs: + housekeeping: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Checkout PSPublishModule + uses: actions/checkout@v4 + with: + repository: EvotecIT/PSPublishModule + ref: ${{ inputs['powerforge-ref'] }} + path: .powerforge/pspublishmodule + + - name: Run PowerForge housekeeping + uses: ./.powerforge/pspublishmodule/.github/actions/github-housekeeping + with: + config-path: ${{ inputs['config-path'] }} + apply: ${{ inputs.apply && 'true' || 'false' }} + github-token: ${{ secrets['github-token'] != '' && secrets['github-token'] || github.token }} diff --git a/.gitignore b/.gitignore index 9e0b6d50..367b158a 100644 --- a/.gitignore +++ b/.gitignore @@ -201,6 +201,8 @@ DocProject/Help/html # Click-Once directory publish/ +!PowerForgeStudio.Domain/Publish/ +!PowerForgeStudio.Domain/Publish/*.cs # Publish Web Output *.[Pp]ublish.xml diff --git a/.powerforge/github-housekeeping.json b/.powerforge/github-housekeeping.json new file mode 100644 index 00000000..18677ea6 --- /dev/null +++ b/.powerforge/github-housekeeping.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/github.housekeeping.schema.json", + "repository": "EvotecIT/PSPublishModule", + "tokenEnvName": "GITHUB_TOKEN", + "dryRun": false, + "artifacts": { + "enabled": true, + "keepLatestPerName": 10, + "maxAgeDays": 7, + "maxDelete": 200 + }, + "caches": { + "enabled": true, + "keepLatestPerKey": 2, + "maxAgeDays": 14, + "maxDelete": 200 + }, + "runner": { + "enabled": false + } +} diff --git a/Build/Build-PowerForge.ps1 b/Build/Build-PowerForge.ps1 index 32d2fef4..f44b096f 100644 --- a/Build/Build-PowerForge.ps1 +++ b/Build/Build-PowerForge.ps1 @@ -1,4 +1,6 @@ [CmdletBinding()] param( + [ValidateSet('PowerForge', 'PowerForgeWeb', 'All')] + [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')] @@ -12,163 +14,402 @@ [switch] $Zip, [switch] $UseStaging = $true, [switch] $KeepSymbols, - [switch] $KeepDocs + [switch] $KeepDocs, + [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 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -function Write-Header($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan } -function Write-Ok($t) { Write-Host ("{0} {1}" -f ([char]0x2705), $t) -ForegroundColor Green } # ✅ -function Write-Step($t) { Write-Host ("{0} {1}" -f ([char]0x1F6E0), $t) -ForegroundColor Yellow } # 🛠 (no VS16) +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 -$proj = Join-Path $repoRoot 'PowerForge.Cli/PowerForge.Cli.csproj' +$moduleManifest = Join-Path $repoRoot 'PSPublishModule\PSPublishModule.psd1' +$outDirProvided = $PSBoundParameters.ContainsKey('OutDir') -and -not [string]::IsNullOrWhiteSpace($OutDir) -if (-not (Test-Path -LiteralPath $proj)) { throw "Project not found: $proj" } +if ($PublishGitHub) { + $Zip = $true +} -# When using a staging publish dir, treat the output as an exact snapshot and clear it by default. -if ($UseStaging -and -not $PSBoundParameters.ContainsKey('ClearOut')) { - $ClearOut = $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') + } } -$outDirProvided = $PSBoundParameters.ContainsKey('OutDir') -$rids = @( - @($Runtime) | - Where-Object { $_ -and $_.Trim() } | - ForEach-Object { $_.Trim() } | - Select-Object -Unique -) -if ($rids.Count -eq 0) { throw "Runtime must not be empty." } -$multiRuntime = $rids.Count -gt 1 +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] $Rid) + param( + [Parameter(Mandatory)][string] $ToolName, + [Parameter(Mandatory)][string] $Rid, + [Parameter(Mandatory)][string] $DefaultRoot, + [Parameter(Mandatory)][bool] $MultiTool, + [Parameter(Mandatory)][bool] $MultiRuntime + ) + if ($outDirProvided) { - if ($multiRuntime) { - return Join-Path $OutDir ("{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) + $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 Join-Path $repoRoot ("Artifacts/PowerForge/{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) + + 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.' } +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)" +} + +$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.' +} +$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 PowerForge ($Flavor)" +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 ($rid in $rids) { - $outDirThis = Resolve-OutDir -Rid $rid - New-Item -ItemType Directory -Force -Path $outDirThis | Out-Null - - $publishDir = $outDirThis - $stagingDir = $null - if ($UseStaging) { - $stagingDir = Join-Path $env:TEMP ("PowerForge.Cli.publish." + [guid]::NewGuid().ToString("N")) - $publishDir = $stagingDir - Write-Step "Using staging publish dir -> $publishDir" - if (Test-Path $publishDir) { - Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue - } - New-Item -ItemType Directory -Force -Path $publishDir | Out-Null - } - - Write-Step "Runtime -> $rid" - Write-Step "Publishing -> $publishDir" - - $publishArgs = @( - 'publish', $proj, - '-c', $Configuration, - '-f', $Framework, - '-r', $rid, - "--self-contained:$selfContained", - "/p:PublishSingleFile=$singleFile", - "/p:PublishReadyToRun=false", - "/p:PublishTrimmed=false", - "/p:IncludeAllContentForSelfExtract=$singleFile", - "/p:IncludeNativeLibrariesForSelfExtract=$selfExtract", - "/p:EnableCompressionInSingleFile=$compress", - "/p:DebugType=None", - "/p:DebugSymbols=false", - "/p:GenerateDocumentationFile=false", - "/p:CopyDocumentationFiles=false", - "/p:ExcludeSymbolsFromSingleFile=true", - "/p:ErrorOnDuplicatePublishOutputFiles=false", - "/p:UseAppHost=true", - "/p:PublishDir=$publishDir" - ) +foreach ($toolName in $selectedTools) { + $definition = $toolDefinitions[$toolName] + if (-not $definition) { + throw "Unsupported tool: $toolName" + } - if ($ClearOut -and (Test-Path $outDirThis) -and ($publishDir -eq $outDirThis)) { - Write-Step "Clearing $outDirThis" - Get-ChildItem -Path $outDirThis -Recurse -Force -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + $projectPath = [string] $definition.ProjectPath + if (-not (Test-Path -LiteralPath $projectPath)) { + throw "Project not found: $projectPath" } - dotnet @publishArgs - if ($LASTEXITCODE -ne 0) { throw "Publish failed ($LASTEXITCODE)" } + $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] = @() - if (-not $KeepSymbols) { - Write-Step "Removing symbols (*.pdb)" - Get-ChildItem -Path $publishDir -Filter *.pdb -File -Recurse -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - } + 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 - 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 - } + $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" - $ridIsWindows = $rid -like 'win-*' - $cliExe = Join-Path $publishDir 'PowerForge.Cli.exe' - $cliExeAlt = Join-Path $publishDir 'PowerForge.Cli' - $friendlyExe = Join-Path $publishDir ($ridIsWindows ? 'powerforge.exe' : 'powerforge') - foreach ($candidate in @($cliExe, $cliExeAlt)) { - if (-not (Test-Path -LiteralPath $candidate)) { continue } + $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" + ) - # Keep a stable, user-friendly binary name without duplicating 50+ MB on disk. - if (Test-Path -LiteralPath $friendlyExe) { - Remove-Item -LiteralPath $friendlyExe -Force -ErrorAction SilentlyContinue + if ($ClearOut -and (Test-Path -LiteralPath $outDirThis) -and ($publishDir -eq $outDirThis)) { + Write-Step "Clearing $outDirThis" + Remove-DirectoryContents -Path $outDirThis } - Move-Item -LiteralPath $candidate -Destination $friendlyExe -Force - break - } - if ($ClearOut -and (Test-Path $outDirThis) -and ($publishDir -ne $outDirThis)) { - Write-Step "Clearing $outDirThis" - Get-ChildItem -Path $outDirThis -Recurse -Force -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue - } + dotnet @publishArgs + if ($LASTEXITCODE -ne 0) { + throw "Publish failed for $toolName ($LASTEXITCODE)" + } - if ($publishDir -ne $outDirThis) { - Write-Step "Copying publish output -> $outDirThis" - Copy-Item -Path (Join-Path $publishDir '*') -Destination $outDirThis -Recurse -Force + 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 ($Zip) { - $zipName = "PowerForge-" + $Framework + "-" + $rid + "-" + $Flavor + "-" + (Get-Date -Format 'yyyyMMdd-HHmm') + ".zip" - $zipPath = Join-Path (Split-Path -Parent $outDirThis) $zipName - if (Test-Path $zipPath) { Remove-Item -Force $zipPath } - Write-Step ("Create zip -> {0}" -f $zipPath) - Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::CreateFromDirectory($outDirThis, $zipPath) + if (-not (Test-Path -LiteralPath $toolOutputRoot)) { + New-Item -ItemType Directory -Force -Path $toolOutputRoot | Out-Null } - if ($stagingDir -and (Test-Path $stagingDir)) { - Remove-Item -Path $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + $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 ($multiRuntime) { - $root = if ($outDirProvided) { $OutDir } else { Join-Path $repoRoot 'Artifacts/PowerForge' } - Write-Ok ("Built PowerForge -> {0}" -f $root) -} else { - Write-Ok ("Built PowerForge -> {0}" -f (Resolve-OutDir -Rid $rids[0])) +if ($multiTool) { + $root = if ($outDirProvided) { $OutDir } else { Join-Path $repoRoot 'Artifacts' } + Write-Ok "Built tool artifacts -> $root" } diff --git a/Build/Build-PowerForgeWeb.ps1 b/Build/Build-PowerForgeWeb.ps1 new file mode 100644 index 00000000..1923b221 --- /dev/null +++ b/Build/Build-PowerForgeWeb.ps1 @@ -0,0 +1,60 @@ +[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] $PublishGitHub, + [string] $GitHubUsername = 'EvotecIT', + [string] $GitHubRepositoryName = 'PSPublishModule', + [string] $GitHubAccessToken, + [string] $GitHubAccessTokenFilePath, + [string] $GitHubAccessTokenEnvName = 'GITHUB_TOKEN', + [string] $GitHubTagName, + [string] $GitHubReleaseName, + [switch] $GenerateReleaseNotes = $true, + [switch] $IsPreRelease +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Build-PowerForge.ps1' +$invokeParams = @{ + Tool = 'PowerForgeWeb' + 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 ($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 +} diff --git a/Build/Build-Project.ps1 b/Build/Build-Project.ps1 new file mode 100644 index 00000000..a4746073 --- /dev/null +++ b/Build/Build-Project.ps1 @@ -0,0 +1,23 @@ +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 +) + +Import-Module PSPublishModule -Force -ErrorAction Stop + +$invokeParams = @{ + ConfigPath = $ConfigPath +} +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 diff --git a/Build/project.build.json b/Build/project.build.json new file mode 100644 index 00000000..e4d9a671 --- /dev/null +++ b/Build/project.build.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/project.build.schema.json", + "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}" +} diff --git a/Docs/PSPublishModule.ProjectBuild.md b/Docs/PSPublishModule.ProjectBuild.md index 10ad5ae9..ab83b539 100644 --- a/Docs/PSPublishModule.ProjectBuild.md +++ b/Docs/PSPublishModule.ProjectBuild.md @@ -72,6 +72,8 @@ Staging and outputs - `StagingPath`: root directory for pipeline outputs (recommended). - Packages go to `\packages` when `OutputPath` is not set. - Release zips go to `\releases` when `ReleaseZipOutputPath` is not set. +- When a project defines ``, project-build uses that package identity for NuGet version lookup, + planned `.nupkg` names, and release zip names. Otherwise it falls back to the csproj file name. - `CleanStaging`: if true, deletes the staging directory before a run. - `PlanOutputPath`: optional file path for a JSON plan output. diff --git a/Docs/PowerForgeStudio.FoundationPlan.md b/Docs/PowerForgeStudio.FoundationPlan.md index 4dd46bb6..55ff2f08 100644 --- a/Docs/PowerForgeStudio.FoundationPlan.md +++ b/Docs/PowerForgeStudio.FoundationPlan.md @@ -8,6 +8,9 @@ Last updated: 2026-03-09 PowerForge/PSPublishModule module builds, project builds, signing, publish approvals, and repo health across the maintainer's GitHub workspace. +Architecture regroup note: +- before adding more Studio execution features, follow `Docs\PowerForgeStudio.RegroupPlan.md` as the boundary contract for shared PowerForge reuse, thin cmdlets, and future provider integrations. + The name is intentionally provisional. The architecture should assume the product name may change without forcing namespace or storage rewrites outside the new app projects. diff --git a/Docs/PowerForgeStudio.RegroupPlan.md b/Docs/PowerForgeStudio.RegroupPlan.md new file mode 100644 index 00000000..6d69be59 --- /dev/null +++ b/Docs/PowerForgeStudio.RegroupPlan.md @@ -0,0 +1,339 @@ +# PowerForgeStudio Regroup Plan + +Last updated: 2026-03-12 + +## Purpose + +Pause feature-first PowerForge Studio work long enough to lock the architecture boundary: + +- `PSPublishModule` cmdlets stay thin. +- reusable build/publish/sign/verify logic lives in `PowerForge` / `PowerForge.PowerShell`. +- `PowerForgeStudio` acts as an orchestration shell over those services, not a second build engine. +- future integrations such as `IntelligenceX` plug into provider seams instead of forcing Studio-specific business rules into the queue runner. + +## What Is Already Good + +The repo already contains two strong patterns worth preserving: + +1. Cmdlets are mostly orchestration shells over shared services. + - `Invoke-ProjectBuild` prepares inputs and calls `ProjectBuildWorkflowService`. + - `Invoke-ModuleBuild` prepares inputs and calls `ModuleBuildWorkflowService`. +2. Studio already has host reuse inside its own app boundary. + - `PowerForgeStudio.Orchestrator` is consumed by both `PowerForgeStudio.Wpf` and `PowerForgeStudio.Cli`. + - workspace snapshots, queue commands, and portfolio projections already live outside WPF. + +Those are the right instincts. The regroup work is about extending the same discipline across the repo boundary. + +## Current Drift We Need To Stop + +### 1. Studio does not consume the shared build engines directly + +`PowerForgeStudio.Orchestrator` currently references: + +- `PowerForgeStudio.Domain` +- `DbaClientX.SQLite` + +It does **not** reference: + +- `PowerForge` +- `PowerForge.PowerShell` + +That means Studio cannot call the same reusable workflow services that the cmdlets call today. + +### 2. Studio re-creates execution behavior through inline PowerShell scripts + +Current examples: + +- `RepositoryPlanPreviewService` builds ad-hoc scripts for `Invoke-ProjectBuild` and `Invoke-ModuleBuild`. +- `ReleaseBuildExecutionService` rewrites project config JSON and patches module DSL behavior by wrapping `New-ConfigurationBuild`. +- `ReleasePublishExecutionService` mixes direct `dotnet nuget push`, `Send-GitHubRelease`, and Studio-owned publish receipt logic. + +This works as a bootstrap, but it creates a second orchestration layer with different defaults, safety rails, and failure shapes than the shared engine. + +### 3. Engine workflows are reusable in design, but not yet host-facing in shape + +The core workflow services exist: + +- `ProjectBuildWorkflowService` +- `ModuleBuildWorkflowService` +- `DotNetRepositoryReleaseWorkflowService` +- publish/signing helpers across `PowerForge` and `PowerForge.PowerShell` + +But the workflow entry points are still effectively cmdlet-internal: + +- many services are `internal` +- some assume cmdlet-style setup or logging +- Studio-friendly request/response contracts are not yet first-class + +### 4. Queue orchestration and execution orchestration are mixed together + +Studio should own: + +- queue ordering +- checkpoint persistence +- approval flow +- repo/worktree attention views + +Studio should not own: + +- package push semantics +- GitHub release composition +- module build plan shaping +- repo build-policy mutation + +Right now some of that lower-level execution behavior is duplicated inside Studio adapters. + +## Architecture Decision + +### Rule 0: Studio does not call cmdlets + +`PSPublishModule` cmdlets are for PowerShell hosts. + +Studio should not execute cmdlets as its normal integration path. +Studio should call reusable C# services and typed contracts exposed by: + +- `PowerForge` +- `PowerForge.PowerShell` + +If a cmdlet exists first, that is a signal to extract or expose the reusable C# logic underneath it, not a reason for Studio to invoke the cmdlet. + +### Rule 1: one execution engine, many hosts + +All build/release business logic that could be reused by: + +- cmdlets +- Studio +- CLI +- tests +- future services + +must live in `PowerForge` or `PowerForge.PowerShell`. + +`PSPublishModule` remains a PowerShell host adapter. +`PowerForgeStudio` remains a desktop/CLI orchestration host. + +### Rule 2: Studio coordinates workflows, it does not redefine them + +Studio is responsible for: + +- discovering repositories and worktrees +- deciding what should run next +- storing queue state and receipts +- showing status, blockers, and approvals +- selecting the right shared adapter for module/library/mixed repos + +Studio is **not** responsible for inventing alternate implementations of: + +- plan generation +- build execution +- publish execution +- signing behavior +- verification rules + +### Rule 3: PowerShell-specific compatibility stays in `PowerForge.PowerShell` + +Anything tied to: + +- DSL scriptblocks +- `ScriptBlock` +- module manifest conventions +- PowerShell repository registration/install behavior +- PowerShell host/runtime concerns + +belongs in `PowerForge.PowerShell`, even when Studio later invokes it through a C# API. + +### Rule 4: repo-specific rules become adapters, not shell logic + +Per-repository differences should resolve into small adapters that answer: + +- what contract does this repo use? +- where is the config or entrypoint? +- which shared workflow request should be built? +- which capabilities are supported? + +The adapter may map repo layout into a shared request, but the actual execution should happen in shared PowerForge services. + +### Rule 5: external tools live behind reusable infrastructure services + +Sometimes the right implementation still requires an external executable or host-specific process, for example: + +- `git` +- `dotnet` +- `pwsh` / `powershell` +- signing tools + +That is acceptable only when the process boundary is wrapped by a reusable C# service with typed inputs and outputs. + +Do not scatter raw command strings or per-host process logic across: + +- Studio UI +- Studio queue adapters +- cmdlets +- repo-specific orchestration code + +Instead, keep the process boundary in one reusable service and let every host call that same service. + +## Command Boundary Policy + +Preferred order of implementation: + +1. pure C# logic inside `PowerForge` / `PowerForge.PowerShell` +2. reusable C# service that wraps an external executable with typed request/result contracts +3. host adapter (`PSPublishModule`, Studio, CLI, WPF) that calls the reusable C# service + +Avoid this order: + +1. Studio builds a shell string +2. Studio calls a cmdlet +3. Studio parses console text and treats it as business state + +## Git Example + +`git` is the right example for the rule above. + +Target shape: + +- a reusable Git service in shared code +- typed methods for operations such as status, fetch, pull, push, switch, branch creation, and upstream setup +- typed results for exit code, stdout/stderr, parsed branch state, and remediation hints + +Consumers: + +- Studio can ask for git status, safe actions, and command execution through one shared service. +- `PowerForge` workflows can reuse the same git service for release preflight or repo automation. +- cmdlets can call the same service when PowerShell needs that functionality. + +Non-goal: + +- Studio owning its own git command catalog, its own process runner strategy, and its own parsing rules forever. + +## Target Layering + +### `PowerForge` + +Owns host-agnostic build/release logic: + +- project/library planning and execution +- NuGet/GitHub publish primitives +- external tool abstractions such as git/dotnet/process-backed infrastructure when pure C# is not possible +- signing workflows +- verification workflows +- typed request/result models + +### `PowerForge.PowerShell` + +Owns PowerShell-specific reusable logic: + +- module DSL preparation +- module build preparation/workflow +- PowerShell repository/module publish behavior +- PowerShell-host-adjacent compatibility services + +### `PSPublishModule` + +Owns only PowerShell UX: + +- parameter binding +- `ShouldProcess` +- host rendering +- mapping shared results to stable cmdlet-facing output contracts + +### `PowerForgeStudio.Domain` + +Owns Studio-specific state contracts: + +- queue/session/checkpoint models +- portfolio/workspace projections +- inbox/dashboard/readiness models + +### `PowerForgeStudio.Orchestrator` + +Owns Studio coordination only: + +- repository discovery +- queue planning +- persistence through `DbaClientX.SQLite` +- calling shared PowerForge execution services +- composing shared results into Studio receipts/checkpoints + +### `PowerForgeStudio.Wpf` and `PowerForgeStudio.Cli` + +Own only presentation and host interaction. + +## Concrete Refactor Direction + +### Phase A: expose host-friendly shared execution services + +Before moving more Studio features, add public host-oriented services and contracts in shared libraries for: + +1. project plan/build execution +2. module plan/build execution +3. publish execution +4. verification execution +5. external tool boundaries that Studio currently owns directly (`git` first) + +These should accept typed requests and return typed results without requiring: + +- PowerShell script generation +- temp JSON mutation in Studio +- cmdlet-only wrappers +- raw process command strings embedded in Studio services + +### Phase B: move Studio from script wrappers to shared services + +Replace these Studio seams first: + +1. `RepositoryPlanPreviewService` +2. `ReleaseBuildExecutionService` +3. `ReleasePublishExecutionService` +4. `ReleaseVerificationExecutionService` +5. Studio-owned git execution and parsing seams + +The goal is not “remove PowerShell from existence”; the goal is “Studio stops using PowerShell wrapper scripts as its primary business logic path.” + +### Phase C: keep queue receipts, but base them on shared result contracts + +Studio should still persist its own checkpoint/receipt models, but those receipts should be projections of shared results, not bespoke interpretations of shell output. + +### Phase D: add provider seams for external attention sources + +For future `IntelligenceX` integration, define provider interfaces around attention and governance signals, for example: + +- repository health/inbox items +- release recommendations +- GitHub governance/policy checks +- future automation hints + +That keeps GitHub support strong today without making Studio depend on one product forever. + +## Near-Term Rules For Ongoing Work + +Effective immediately: + +1. Do not add new business logic to `PSPublishModule\Cmdlets\` if Studio or tests could reuse it. +2. Do not have Studio call cmdlets as its primary implementation path. +3. Do not add new Studio-only build/publish/git logic when the same behavior belongs in `PowerForge` / `PowerForge.PowerShell`. +4. Do not treat PowerShell script generation inside Studio as the long-term API surface. +5. Prefer extracting shared request/result contracts before adding new queue stages or new UI affordances. +6. Keep WPF and CLI on the same orchestrator service surface so the shell never becomes the only usable host. + +## Recommended Next PR Order + +1. Introduce a small host-facing execution contract review doc or issue list for project/module/publish/verify services. +2. Extract a reusable shared git/process boundary so Studio no longer owns git execution semantics. +3. Make the shared execution services public where appropriate and normalize request/result models. +4. Refactor Studio project plan/build execution to call shared services directly. +5. Refactor Studio module plan/build execution to call shared services directly through `PowerForge.PowerShell`. +6. Refactor Studio publish/verify execution to reuse shared publish and verification services. +7. Only then continue broader Studio UX work and any IntelligenceX-driven inbox expansion. + +## Definition Of Success + +We are back on track when: + +- cmdlets remain thin and mostly unchanged when new execution logic is added +- Studio can run project/module flows without inventing alternate shell-script wrappers or calling cmdlets +- external tools such as git are wrapped once in reusable C# services instead of being hardcoded in Studio +- the same shared services are testable without PowerShell host plumbing +- WPF and CLI remain thin over one Studio orchestrator +- future IntelligenceX integration plugs into provider seams instead of bypassing PowerForge diff --git a/PowerForge.Cli/PowerForge.Cli.csproj b/PowerForge.Cli/PowerForge.Cli.csproj index a1c1573b..50d1a332 100644 --- a/PowerForge.Cli/PowerForge.Cli.csproj +++ b/PowerForge.Cli/PowerForge.Cli.csproj @@ -7,11 +7,19 @@ enable PowerForge.Cli PowerForge.Cli + PowerForge.Build true powerforge PowerForge command-line interface for building and publishing PowerShell modules. 1.0.0 true + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + powershell;build;publishing;automation;cli;tool + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge.Cli/PowerForgeCliJsonContext.cs b/PowerForge.Cli/PowerForgeCliJsonContext.cs index 9951a14f..a1686c61 100644 --- a/PowerForge.Cli/PowerForgeCliJsonContext.cs +++ b/PowerForge.Cli/PowerForgeCliJsonContext.cs @@ -33,7 +33,11 @@ namespace PowerForge.Cli; [JsonSerializable(typeof(DotNetPublishResult))] [JsonSerializable(typeof(DotNetPublishFailure))] [JsonSerializable(typeof(DotNetPublishConfigScaffoldResult))] +[JsonSerializable(typeof(GitHubHousekeepingSpec))] +[JsonSerializable(typeof(GitHubHousekeepingResult))] [JsonSerializable(typeof(GitHubArtifactCleanupResult))] +[JsonSerializable(typeof(GitHubActionsCacheCleanupResult))] +[JsonSerializable(typeof(RunnerHousekeepingResult))] [JsonSerializable(typeof(ArtefactBuildResult[]))] [JsonSerializable(typeof(NormalizationResult[]))] [JsonSerializable(typeof(FormatterResult[]))] diff --git a/PowerForge.Cli/Program.Command.GitHub.cs b/PowerForge.Cli/Program.Command.GitHub.cs index e664f0c9..2b271751 100644 --- a/PowerForge.Cli/Program.Command.GitHub.cs +++ b/PowerForge.Cli/Program.Command.GitHub.cs @@ -2,128 +2,358 @@ using PowerForge.Cli; using System; using System.Collections.Generic; +using System.IO; using System.Linq; internal static partial class Program { + private const string GitHubArtifactsPruneUsage = "Usage: powerforge github artifacts prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--name ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json]"; + private const string GitHubCachesPruneUsage = "Usage: powerforge github caches prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--key ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json]"; + private const string GitHubHousekeepingUsage = "Usage: powerforge github housekeeping [--config ] [--repo ] [--api-base-url ] [--token-env ] [--token ] [--dry-run|--apply] [--output json]"; + private const string GitHubRunnerCleanupUsage = "Usage: powerforge github runner cleanup [--runner-temp ] [--work-root ] [--runner-root ] [--diag-root ] [--tool-cache ] [--min-free-gb ] [--aggressive-threshold-gb ] [--diag-retention-days ] [--actions-retention-days ] [--tool-cache-retention-days ] [--dry-run|--apply] [--aggressive] [--allow-sudo] [--skip-diagnostics] [--skip-runner-temp] [--skip-actions-cache] [--skip-tool-cache] [--skip-dotnet-cache] [--skip-docker] [--no-docker-volumes] [--output json]"; + private static int CommandGitHub(string[] filteredArgs, CliOptions cli, ILogger logger) { var argv = filteredArgs.Skip(1).ToArray(); - if (argv.Length == 0 || argv[0].Equals("-h", StringComparison.OrdinalIgnoreCase) || argv[0].Equals("--help", StringComparison.OrdinalIgnoreCase)) + if (argv.Length == 0 || IsHelpArg(argv[0])) { - Console.WriteLine("Usage: powerforge github artifacts prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--name ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json]"); + Console.WriteLine(GitHubArtifactsPruneUsage); + Console.WriteLine(GitHubCachesPruneUsage); + Console.WriteLine(GitHubHousekeepingUsage); + Console.WriteLine(GitHubRunnerCleanupUsage); return 2; } - var sub = argv[0].ToLowerInvariant(); - switch (sub) + return argv[0].ToLowerInvariant() switch + { + "artifacts" => CommandGitHubArtifacts(argv.Skip(1).ToArray(), cli, logger), + "caches" => CommandGitHubCaches(argv.Skip(1).ToArray(), cli, logger), + "housekeeping" => CommandGitHubHousekeeping(argv.Skip(1).ToArray(), cli, logger), + "runner" => CommandGitHubRunner(argv.Skip(1).ToArray(), cli, logger), + _ => UnknownGitHubCommand() + }; + } + + private static int CommandGitHubArtifacts(string[] argv, CliOptions cli, ILogger logger) + { + if (argv.Length == 0 || IsHelpArg(argv[0])) { - case "artifacts": + Console.WriteLine(GitHubArtifactsPruneUsage); + return 2; + } + + if (!argv[0].Equals("prune", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(GitHubArtifactsPruneUsage); + return 2; + } + + var pruneArgs = argv.Skip(1).ToArray(); + var outputJson = IsJsonOutput(pruneArgs); + + GitHubArtifactCleanupSpec spec; + try + { + spec = ParseGitHubArtifactPruneArgs(pruneArgs); + } + catch (Exception ex) + { + return WriteGitHubCommandArgumentError(outputJson, "github.artifacts.prune", ex.Message, GitHubArtifactsPruneUsage, logger); + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var service = new GitHubArtifactCleanupService(cmdLogger); + var statusText = spec.DryRun ? "Planning GitHub artifact cleanup" : "Pruning GitHub artifacts"; + var result = RunWithStatus(outputJson, cli, statusText, () => service.Prune(spec)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) { - var subArgs = argv.Skip(1).ToArray(); - if (subArgs.Length == 0 || subArgs[0].Equals("-h", StringComparison.OrdinalIgnoreCase) || subArgs[0].Equals("--help", StringComparison.OrdinalIgnoreCase)) + WriteJson(new CliJsonEnvelope { - Console.WriteLine("Usage: powerforge github artifacts prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--name ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json]"); - return 2; - } + SchemaVersion = OutputSchemaVersion, + Command = "github.artifacts.prune", + Success = result.Success, + ExitCode = exitCode, + Result = CliJson.SerializeToElement(result, CliJson.Context.GitHubArtifactCleanupResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; + } - if (!subArgs[0].Equals("prune", StringComparison.OrdinalIgnoreCase)) - { - Console.WriteLine("Usage: powerforge github artifacts prune [options]"); - return 2; - } + var mode = result.DryRun ? "Dry run" : "Applied"; + logger.Info($"{mode}: {result.Repository}"); + logger.Info($"Scanned: {result.ScannedArtifacts}, matched: {result.MatchedArtifacts}"); + logger.Info($"Planned deletes: {result.PlannedDeletes} ({result.PlannedDeleteBytes} bytes)"); + if (!result.DryRun) + { + logger.Info($"Deleted: {result.DeletedArtifacts} ({result.DeletedBytes} bytes)"); + if (result.FailedDeletes > 0) + logger.Warn($"Failed deletes: {result.FailedDeletes}"); + } - var pruneArgs = subArgs.Skip(1).ToArray(); - var outputJson = IsJsonOutput(pruneArgs); + if (!string.IsNullOrWhiteSpace(result.Message)) + logger.Warn(result.Message!); - GitHubArtifactCleanupSpec spec; - try - { - spec = ParseGitHubArtifactPruneArgs(pruneArgs); - } - catch (Exception ex) + return exitCode; + } + catch (Exception ex) + { + return WriteGitHubCommandFailure(outputJson, "github.artifacts.prune", ex.Message, logger); + } + } + + private static int CommandGitHubCaches(string[] argv, CliOptions cli, ILogger logger) + { + if (argv.Length == 0 || IsHelpArg(argv[0])) + { + Console.WriteLine(GitHubCachesPruneUsage); + return 2; + } + + if (!argv[0].Equals("prune", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(GitHubCachesPruneUsage); + return 2; + } + + var pruneArgs = argv.Skip(1).ToArray(); + var outputJson = IsJsonOutput(pruneArgs); + + GitHubActionsCacheCleanupSpec spec; + try + { + spec = ParseGitHubCachesPruneArgs(pruneArgs); + } + catch (Exception ex) + { + return WriteGitHubCommandArgumentError(outputJson, "github.caches.prune", ex.Message, GitHubCachesPruneUsage, logger); + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var service = new GitHubActionsCacheCleanupService(cmdLogger); + var statusText = spec.DryRun ? "Planning GitHub cache cleanup" : "Pruning GitHub caches"; + var result = RunWithStatus(outputJson, cli, statusText, () => service.Prune(spec)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) + { + WriteJson(new CliJsonEnvelope { - if (outputJson) - { - WriteJson(new CliJsonEnvelope - { - SchemaVersion = OutputSchemaVersion, - Command = "github.artifacts.prune", - Success = false, - ExitCode = 2, - Error = ex.Message - }); - return 2; - } - - logger.Error(ex.Message); - Console.WriteLine("Usage: powerforge github artifacts prune [options]"); - return 2; - } - - try + SchemaVersion = OutputSchemaVersion, + Command = "github.caches.prune", + Success = result.Success, + ExitCode = exitCode, + Result = CliJson.SerializeToElement(result, CliJson.Context.GitHubActionsCacheCleanupResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; + } + + var mode = result.DryRun ? "Dry run" : "Applied"; + logger.Info($"{mode}: {result.Repository}"); + if (result.UsageBefore is not null) + logger.Info($"GitHub cache usage before cleanup: {result.UsageBefore.ActiveCachesCount} caches, {result.UsageBefore.ActiveCachesSizeInBytes} bytes"); + logger.Info($"Scanned: {result.ScannedCaches}, matched: {result.MatchedCaches}"); + logger.Info($"Planned deletes: {result.PlannedDeletes} ({result.PlannedDeleteBytes} bytes)"); + if (!result.DryRun) + { + logger.Info($"Deleted: {result.DeletedCaches} ({result.DeletedBytes} bytes)"); + if (result.FailedDeletes > 0) + logger.Warn($"Failed deletes: {result.FailedDeletes}"); + } + + if (!string.IsNullOrWhiteSpace(result.Message)) + logger.Warn(result.Message!); + + return exitCode; + } + catch (Exception ex) + { + return WriteGitHubCommandFailure(outputJson, "github.caches.prune", ex.Message, logger); + } + } + + private static int CommandGitHubRunner(string[] argv, CliOptions cli, ILogger logger) + { + if (argv.Length == 0 || IsHelpArg(argv[0])) + { + Console.WriteLine(GitHubRunnerCleanupUsage); + return 2; + } + + if (!argv[0].Equals("cleanup", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(GitHubRunnerCleanupUsage); + return 2; + } + + var cleanupArgs = argv.Skip(1).ToArray(); + var outputJson = IsJsonOutput(cleanupArgs); + + RunnerHousekeepingSpec spec; + try + { + spec = ParseGitHubRunnerCleanupArgs(cleanupArgs); + } + catch (Exception ex) + { + return WriteGitHubCommandArgumentError(outputJson, "github.runner.cleanup", ex.Message, GitHubRunnerCleanupUsage, logger); + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var service = new RunnerHousekeepingService(cmdLogger); + var statusText = spec.DryRun ? "Planning runner housekeeping" : "Cleaning runner working sets"; + var result = RunWithStatus(outputJson, cli, statusText, () => service.Clean(spec)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) + { + WriteJson(new CliJsonEnvelope { - var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); - var service = new GitHubArtifactCleanupService(cmdLogger); - var statusText = spec.DryRun - ? "Planning GitHub artifact cleanup" - : "Pruning GitHub artifacts"; - var result = RunWithStatus(outputJson, cli, statusText, () => service.Prune(spec)); - var exitCode = result.Success ? 0 : 1; - - if (outputJson) - { - WriteJson(new CliJsonEnvelope - { - SchemaVersion = OutputSchemaVersion, - Command = "github.artifacts.prune", - Success = result.Success, - ExitCode = exitCode, - Result = CliJson.SerializeToElement(result, CliJson.Context.GitHubArtifactCleanupResult), - Logs = LogsToJsonElement(logBuffer) - }); - return exitCode; - } - - var mode = result.DryRun ? "Dry run" : "Applied"; - logger.Info($"{mode}: {result.Repository}"); - logger.Info($"Scanned: {result.ScannedArtifacts}, matched: {result.MatchedArtifacts}"); - logger.Info($"Planned deletes: {result.PlannedDeletes} ({result.PlannedDeleteBytes} bytes)"); - if (!result.DryRun) - { - logger.Info($"Deleted: {result.DeletedArtifacts} ({result.DeletedBytes} bytes)"); - if (result.FailedDeletes > 0) - logger.Warn($"Failed deletes: {result.FailedDeletes}"); - } - - if (!string.IsNullOrWhiteSpace(result.Message)) - logger.Warn(result.Message!); - - return exitCode; - } - catch (Exception ex) + SchemaVersion = OutputSchemaVersion, + Command = "github.runner.cleanup", + Success = result.Success, + ExitCode = exitCode, + Result = CliJson.SerializeToElement(result, CliJson.Context.RunnerHousekeepingResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; + } + + var mode = result.DryRun ? "Dry run" : "Applied"; + logger.Info($"{mode}: {result.RunnerRootPath}"); + logger.Info($"Free before: {result.FreeBytesBefore} bytes"); + logger.Info($"Free after: {result.FreeBytesAfter} bytes"); + logger.Info($"Aggressive cleanup: {(result.AggressiveApplied ? "yes" : "no")}"); + if (!string.IsNullOrWhiteSpace(result.Message)) + logger.Warn(result.Message!); + + return exitCode; + } + catch (Exception ex) + { + return WriteGitHubCommandFailure(outputJson, "github.runner.cleanup", ex.Message, logger); + } + } + + private static int CommandGitHubHousekeeping(string[] argv, CliOptions cli, ILogger logger) + { + var outputJson = IsJsonOutput(argv); + if (argv.Length > 0 && IsHelpArg(argv[0])) + { + Console.WriteLine(GitHubHousekeepingUsage); + return 2; + } + + GitHubHousekeepingSpec spec; + try + { + spec = ParseGitHubHousekeepingArgs(argv); + } + catch (Exception ex) + { + return WriteGitHubCommandArgumentError(outputJson, "github.housekeeping", ex.Message, GitHubHousekeepingUsage, logger); + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var service = new GitHubHousekeepingService(cmdLogger); + var statusText = spec.DryRun ? "Planning GitHub housekeeping" : "Running GitHub housekeeping"; + var result = RunWithStatus(outputJson, cli, statusText, () => service.Run(spec)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) + { + WriteJson(new CliJsonEnvelope { - if (outputJson) - { - WriteJson(new CliJsonEnvelope - { - SchemaVersion = OutputSchemaVersion, - Command = "github.artifacts.prune", - Success = false, - ExitCode = 1, - Error = ex.Message - }); - return 1; - } - - logger.Error(ex.Message); - return 1; - } + SchemaVersion = OutputSchemaVersion, + Command = "github.housekeeping", + Success = result.Success, + ExitCode = exitCode, + Result = CliJson.SerializeToElement(result, CliJson.Context.GitHubHousekeepingResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; } - default: - Console.WriteLine("Usage: powerforge github artifacts prune [options]"); - return 2; + + var mode = result.DryRun ? "Dry run" : "Applied"; + logger.Info($"{mode}: {result.Repository ?? "(runner-only)"}"); + logger.Info($"Requested sections: {string.Join(", ", result.RequestedSections)}"); + if (result.CompletedSections.Length > 0) + logger.Info($"Completed sections: {string.Join(", ", result.CompletedSections)}"); + if (result.FailedSections.Length > 0) + logger.Warn($"Failed sections: {string.Join(", ", result.FailedSections)}"); + + if (result.Caches?.UsageBefore is not null) + logger.Info($"GitHub cache usage before cleanup: {result.Caches.UsageBefore.ActiveCachesCount} caches, {result.Caches.UsageBefore.ActiveCachesSizeInBytes} bytes"); + if (result.Caches?.UsageAfter is not null) + logger.Info($"GitHub cache usage after cleanup: {result.Caches.UsageAfter.ActiveCachesCount} caches, {result.Caches.UsageAfter.ActiveCachesSizeInBytes} bytes"); + + if (!string.IsNullOrWhiteSpace(result.Message)) + logger.Warn(result.Message!); + + return exitCode; } + catch (Exception ex) + { + return WriteGitHubCommandFailure(outputJson, "github.housekeeping", ex.Message, logger); + } + } + + private static int UnknownGitHubCommand() + { + Console.WriteLine(GitHubArtifactsPruneUsage); + Console.WriteLine(GitHubCachesPruneUsage); + Console.WriteLine(GitHubHousekeepingUsage); + Console.WriteLine(GitHubRunnerCleanupUsage); + return 2; + } + + private static int WriteGitHubCommandArgumentError(bool outputJson, string command, string error, string usage, ILogger logger) + { + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = command, + Success = false, + ExitCode = 2, + Error = error + }); + return 2; + } + + logger.Error(error); + Console.WriteLine(usage); + return 2; + } + + private static int WriteGitHubCommandFailure(bool outputJson, string command, string error, ILogger logger) + { + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = command, + Success = false, + ExitCode = 1, + Error = error + }); + return 1; + } + + logger.Error(error); + return 1; } private static GitHubArtifactCleanupSpec ParseGitHubArtifactPruneArgs(string[] argv) @@ -170,31 +400,19 @@ private static GitHubArtifactCleanupSpec ParseGitHubArtifactPruneArgs(string[] a break; case "--keep": case "--keep-latest": - if (++i >= argv.Length || !int.TryParse(argv[i], out var keep)) - throw new InvalidOperationException("Invalid --keep value. Expected integer."); - if (keep < 0) - throw new InvalidOperationException("Invalid --keep value. Expected integer >= 0."); - spec.KeepLatestPerName = keep; + spec.KeepLatestPerName = ParseRequiredInt(argv, ref i, "--keep", minimum: 0); break; case "--max-age-days": case "--age-days": - if (++i >= argv.Length || !int.TryParse(argv[i], out var days)) - throw new InvalidOperationException("Invalid --max-age-days value. Expected integer."); - spec.MaxAgeDays = days < 1 ? null : days; + spec.MaxAgeDays = ParseOptionalPositiveInt(argv, ref i, "--max-age-days"); break; case "--max-delete": case "--limit": - if (++i >= argv.Length || !int.TryParse(argv[i], out var maxDelete)) - throw new InvalidOperationException("Invalid --max-delete value. Expected integer."); - if (maxDelete < 1) - throw new InvalidOperationException("Invalid --max-delete value. Expected integer >= 1."); - spec.MaxDelete = maxDelete; + spec.MaxDelete = ParseRequiredInt(argv, ref i, "--max-delete", minimum: 1); break; case "--page-size": case "--per-page": - if (++i >= argv.Length || !int.TryParse(argv[i], out var pageSize)) - throw new InvalidOperationException("Invalid --page-size value. Expected integer."); - spec.PageSize = pageSize; + spec.PageSize = ParseRequiredInt(argv, ref i, "--page-size", minimum: 1); break; case "--dry-run": spec.DryRun = true; @@ -212,23 +430,258 @@ private static GitHubArtifactCleanupSpec ParseGitHubArtifactPruneArgs(string[] a case "--json": break; default: - if (arg.StartsWith("--", StringComparison.Ordinal)) - throw new InvalidOperationException($"Unknown option: {arg}"); + ThrowOnUnknownOption(arg); break; } } - spec.IncludeNames = include - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Select(v => v.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - spec.ExcludeNames = exclude - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Select(v => v.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + spec.IncludeNames = NormalizeCsvValues(include); + spec.ExcludeNames = NormalizeCsvValues(exclude); + ResolveGitHubIdentity(spec, tokenEnv, repoEnv); + return spec; + } + private static GitHubActionsCacheCleanupSpec ParseGitHubCachesPruneArgs(string[] argv) + { + var include = new List(); + var exclude = new List(); + var spec = new GitHubActionsCacheCleanupSpec(); + var tokenEnv = "GITHUB_TOKEN"; + var repoEnv = "GITHUB_REPOSITORY"; + + for (var i = 0; i < argv.Length; i++) + { + var arg = argv[i]; + switch (arg.ToLowerInvariant()) + { + case "--repo": + case "--repository": + spec.Repository = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--token": + spec.Token = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--token-env": + tokenEnv = ++i < argv.Length ? argv[i] : tokenEnv; + break; + case "--repo-env": + repoEnv = ++i < argv.Length ? argv[i] : repoEnv; + break; + case "--api-base-url": + case "--api-url": + spec.ApiBaseUrl = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--key": + case "--keys": + case "--include": + if (++i < argv.Length) + include.AddRange(SplitCsv(argv[i])); + break; + case "--exclude": + case "--exclude-key": + case "--exclude-keys": + if (++i < argv.Length) + exclude.AddRange(SplitCsv(argv[i])); + break; + case "--keep": + case "--keep-latest": + spec.KeepLatestPerKey = ParseRequiredInt(argv, ref i, "--keep", minimum: 0); + break; + case "--max-age-days": + case "--age-days": + spec.MaxAgeDays = ParseOptionalPositiveInt(argv, ref i, "--max-age-days"); + break; + case "--max-delete": + case "--limit": + spec.MaxDelete = ParseRequiredInt(argv, ref i, "--max-delete", minimum: 1); + break; + case "--page-size": + case "--per-page": + spec.PageSize = ParseRequiredInt(argv, ref i, "--page-size", minimum: 1); + break; + case "--dry-run": + spec.DryRun = true; + break; + case "--apply": + spec.DryRun = false; + break; + case "--fail-on-delete-error": + spec.FailOnDeleteError = true; + break; + case "--output": + i++; + break; + case "--output-json": + case "--json": + break; + default: + ThrowOnUnknownOption(arg); + break; + } + } + + spec.IncludeKeys = NormalizeCsvValues(include); + spec.ExcludeKeys = NormalizeCsvValues(exclude); + ResolveGitHubIdentity(spec, tokenEnv, repoEnv); + return spec; + } + + private static GitHubHousekeepingSpec ParseGitHubHousekeepingArgs(string[] argv) + { + var configPath = TryGetOptionValue(argv, "--config"); + if (string.IsNullOrWhiteSpace(configPath)) + configPath = FindDefaultGitHubHousekeepingConfig(Directory.GetCurrentDirectory()); + if (string.IsNullOrWhiteSpace(configPath)) + throw new InvalidOperationException("Housekeeping config file not found. Provide --config or add .powerforge/github-housekeeping.json."); + + var (spec, fullConfigPath) = LoadGitHubHousekeepingSpecWithPath(configPath!); + ResolveGitHubHousekeepingSpecPaths(spec, fullConfigPath); + + var tokenEnv = string.IsNullOrWhiteSpace(spec.TokenEnvName) ? "GITHUB_TOKEN" : spec.TokenEnvName.Trim(); + + for (var i = 0; i < argv.Length; i++) + { + var arg = argv[i]; + switch (arg.ToLowerInvariant()) + { + case "--config": + i++; + break; + case "--repo": + case "--repository": + spec.Repository = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--token": + spec.Token = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--token-env": + tokenEnv = ++i < argv.Length ? argv[i] : tokenEnv; + break; + case "--api-base-url": + case "--api-url": + spec.ApiBaseUrl = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--dry-run": + spec.DryRun = true; + break; + case "--apply": + spec.DryRun = false; + break; + case "--output": + i++; + break; + case "--output-json": + case "--json": + break; + default: + ThrowOnUnknownOption(arg); + break; + } + } + + if ((spec.Artifacts?.Enabled ?? false) || (spec.Caches?.Enabled ?? false)) + { + if (string.IsNullOrWhiteSpace(spec.Repository)) + spec.Repository = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY")?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(spec.Token) && !string.IsNullOrWhiteSpace(tokenEnv)) + spec.Token = Environment.GetEnvironmentVariable(tokenEnv)?.Trim() ?? string.Empty; + } + + spec.TokenEnvName = tokenEnv; + return spec; + } + + private static RunnerHousekeepingSpec ParseGitHubRunnerCleanupArgs(string[] argv) + { + var spec = new RunnerHousekeepingSpec(); + + for (var i = 0; i < argv.Length; i++) + { + var arg = argv[i]; + switch (arg.ToLowerInvariant()) + { + case "--runner-temp": + spec.RunnerTempPath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--work-root": + spec.WorkRootPath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--runner-root": + spec.RunnerRootPath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--diag-root": + case "--diagnostics-root": + spec.DiagnosticsRootPath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--tool-cache": + case "--tool-cache-path": + spec.ToolCachePath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--min-free-gb": + spec.MinFreeGb = ParseOptionalPositiveInt(argv, ref i, "--min-free-gb"); + break; + case "--aggressive-threshold-gb": + spec.AggressiveThresholdGb = ParseOptionalPositiveInt(argv, ref i, "--aggressive-threshold-gb"); + break; + case "--diag-retention-days": + spec.DiagnosticsRetentionDays = ParseRequiredInt(argv, ref i, "--diag-retention-days", minimum: 0); + break; + case "--actions-retention-days": + spec.ActionsRetentionDays = ParseRequiredInt(argv, ref i, "--actions-retention-days", minimum: 0); + break; + case "--tool-cache-retention-days": + spec.ToolCacheRetentionDays = ParseRequiredInt(argv, ref i, "--tool-cache-retention-days", minimum: 0); + break; + case "--dry-run": + spec.DryRun = true; + break; + case "--apply": + spec.DryRun = false; + break; + case "--aggressive": + spec.Aggressive = true; + break; + case "--allow-sudo": + spec.AllowSudo = true; + break; + case "--skip-diagnostics": + spec.CleanDiagnostics = false; + break; + case "--skip-runner-temp": + spec.CleanRunnerTemp = false; + break; + case "--skip-actions-cache": + spec.CleanActionsCache = false; + break; + case "--skip-tool-cache": + spec.CleanToolCache = false; + break; + case "--skip-dotnet-cache": + spec.ClearDotNetCaches = false; + break; + case "--skip-docker": + spec.PruneDocker = false; + break; + case "--no-docker-volumes": + spec.IncludeDockerVolumes = false; + break; + case "--output": + i++; + break; + case "--output-json": + case "--json": + break; + default: + ThrowOnUnknownOption(arg); + break; + } + } + + return spec; + } + + private static void ResolveGitHubIdentity(GitHubArtifactCleanupSpec spec, string tokenEnv, string repoEnv) + { if (string.IsNullOrWhiteSpace(spec.Repository) && !string.IsNullOrWhiteSpace(repoEnv)) spec.Repository = Environment.GetEnvironmentVariable(repoEnv)?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(spec.Token) && !string.IsNullOrWhiteSpace(tokenEnv)) @@ -238,7 +691,52 @@ private static GitHubArtifactCleanupSpec ParseGitHubArtifactPruneArgs(string[] a throw new InvalidOperationException("Repository is required. Provide --repo or set GITHUB_REPOSITORY."); if (string.IsNullOrWhiteSpace(spec.Token)) throw new InvalidOperationException("GitHub token is required. Provide --token or set GITHUB_TOKEN."); + } - return spec; + private static void ResolveGitHubIdentity(GitHubActionsCacheCleanupSpec spec, string tokenEnv, string repoEnv) + { + if (string.IsNullOrWhiteSpace(spec.Repository) && !string.IsNullOrWhiteSpace(repoEnv)) + spec.Repository = Environment.GetEnvironmentVariable(repoEnv)?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(spec.Token) && !string.IsNullOrWhiteSpace(tokenEnv)) + spec.Token = Environment.GetEnvironmentVariable(tokenEnv)?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(spec.Repository)) + throw new InvalidOperationException("Repository is required. Provide --repo or set GITHUB_REPOSITORY."); + if (string.IsNullOrWhiteSpace(spec.Token)) + throw new InvalidOperationException("GitHub token is required. Provide --token or set GITHUB_TOKEN."); + } + + private static string[] NormalizeCsvValues(IEnumerable values) + { + return values + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); } + + private static int ParseRequiredInt(string[] argv, ref int index, string optionName, int minimum) + { + if (++index >= argv.Length || !int.TryParse(argv[index], out var value)) + throw new InvalidOperationException($"Invalid {optionName} value. Expected integer."); + if (value < minimum) + throw new InvalidOperationException($"Invalid {optionName} value. Expected integer >= {minimum}."); + return value; + } + + private static int? ParseOptionalPositiveInt(string[] argv, ref int index, string optionName) + { + if (++index >= argv.Length || !int.TryParse(argv[index], out var value)) + throw new InvalidOperationException($"Invalid {optionName} value. Expected integer."); + return value < 1 ? null : value; + } + + private static void ThrowOnUnknownOption(string arg) + { + if (arg.StartsWith("--", StringComparison.Ordinal)) + throw new InvalidOperationException($"Unknown option: {arg}"); + } + + private static bool IsHelpArg(string arg) + => arg.Equals("-h", StringComparison.OrdinalIgnoreCase) || arg.Equals("--help", StringComparison.OrdinalIgnoreCase); } diff --git a/PowerForge.Cli/Program.Helpers.IOAndJson.cs b/PowerForge.Cli/Program.Helpers.IOAndJson.cs index 2111236f..b3e12b73 100644 --- a/PowerForge.Cli/Program.Helpers.IOAndJson.cs +++ b/PowerForge.Cli/Program.Helpers.IOAndJson.cs @@ -156,6 +156,31 @@ static void ResolvePipelineSpecPaths(ModulePipelineSpec spec, string configFullP return null; } + static string? FindDefaultGitHubHousekeepingConfig(string baseDir) + { + var candidates = new[] + { + "github-housekeeping.json", + Path.Combine(".powerforge", "github-housekeeping.json"), + Path.Combine(".github", "powerforge", "github-housekeeping.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; @@ -238,6 +263,14 @@ static string ResolveExistingFilePath(string path) return (spec, full); } + static (GitHubHousekeepingSpec Value, string FullPath) LoadGitHubHousekeepingSpecWithPath(string path) + { + var full = ResolveExistingFilePath(path); + var json = File.ReadAllText(full); + var spec = CliJson.DeserializeOrThrow(json, CliJson.Context.GitHubHousekeepingSpec, full); + return (spec, full); + } + static (ModuleInstallSpec Value, string FullPath) LoadInstallSpecWithPath(string path) { var full = ResolveExistingFilePath(path); @@ -254,6 +287,20 @@ static string ResolveExistingFilePath(string path) return (spec, full); } + static void ResolveGitHubHousekeepingSpecPaths(GitHubHousekeepingSpec spec, string configFullPath) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var baseDir = Path.GetDirectoryName(configFullPath) ?? Directory.GetCurrentDirectory(); + if (spec.Runner is null) return; + + spec.Runner.RunnerTempPath = ResolvePathFromBaseNullable(baseDir, spec.Runner.RunnerTempPath); + spec.Runner.WorkRootPath = ResolvePathFromBaseNullable(baseDir, spec.Runner.WorkRootPath); + spec.Runner.RunnerRootPath = ResolvePathFromBaseNullable(baseDir, spec.Runner.RunnerRootPath); + spec.Runner.DiagnosticsRootPath = ResolvePathFromBaseNullable(baseDir, spec.Runner.DiagnosticsRootPath); + spec.Runner.ToolCachePath = ResolvePathFromBaseNullable(baseDir, spec.Runner.ToolCachePath); + } + static (string[] Names, string? Version, bool Prerelease, string[] Repositories)? ParseFindArgs(string[] argv) { var names = new List(); diff --git a/PowerForge.Cli/Program.Helpers.cs b/PowerForge.Cli/Program.Helpers.cs index d1dd0a26..65b10c58 100644 --- a/PowerForge.Cli/Program.Helpers.cs +++ b/PowerForge.Cli/Program.Helpers.cs @@ -35,6 +35,13 @@ powerforge publish --path [--repo ] [--tool auto|psresourceget|powe powerforge github artifacts prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--name ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json] + powerforge github caches prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--key ] + [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] + [--fail-on-delete-error] [--output json] + powerforge github runner cleanup [--runner-temp ] [--work-root ] [--runner-root ] [--diag-root ] [--tool-cache ] + [--min-free-gb ] [--aggressive-threshold-gb ] [--dry-run|--apply] [--aggressive] [--allow-sudo] + [--skip-diagnostics] [--skip-runner-temp] [--skip-actions-cache] [--skip-tool-cache] [--skip-dotnet-cache] + [--skip-docker] [--no-docker-volumes] [--output json] --verbose, -Verbose Enable verbose diagnostics --diagnostics Include logs in JSON output --quiet, -q Suppress non-essential output diff --git a/PowerForge.PowerShell/PowerForge.PowerShell.csproj b/PowerForge.PowerShell/PowerForge.PowerShell.csproj index 6ae5092b..4cb7b691 100644 --- a/PowerForge.PowerShell/PowerForge.PowerShell.csproj +++ b/PowerForge.PowerShell/PowerForge.PowerShell.csproj @@ -8,9 +8,17 @@ true CS1591 1.0.0 + PowerForge.PowerShell PowerForge PowerShell-hosted module pipeline services. PowerForge.PowerShell PowerForge + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + powershell;build;publishing;automation;module + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge.Tests/AuthenticodeSigningHostServiceTests.cs b/PowerForge.Tests/AuthenticodeSigningHostServiceTests.cs new file mode 100644 index 00000000..0b16a211 --- /dev/null +++ b/PowerForge.Tests/AuthenticodeSigningHostServiceTests.cs @@ -0,0 +1,46 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class AuthenticodeSigningHostServiceTests +{ + [Fact] + public async Task SignAsync_UsesSharedRegisterCertificateWrapper() + { + PowerShellRunRequest? captured = null; + var service = new AuthenticodeSigningHostService(new StubPowerShellRunner(request => { + captured = request; + return new PowerShellRunResult(0, "signed", string.Empty, "pwsh"); + })); + + var result = await service.SignAsync(new AuthenticodeSigningHostRequest { + SigningPath = @"C:\repo\Artifacts", + IncludePatterns = ["*.ps1", "*.psd1"], + ModulePath = @"C:\repo\Module\PSPublishModule.psd1", + Thumbprint = "thumb", + StoreName = "CurrentUser", + TimeStampServer = "http://timestamp.digicert.com" + }); + + Assert.NotNull(captured); + Assert.Equal(PowerShellInvocationMode.Command, captured!.InvocationMode); + Assert.Equal(@"C:\repo\Artifacts", captured.WorkingDirectory); + Assert.Contains("Register-Certificate", captured.CommandText!, StringComparison.Ordinal); + Assert.Contains("-Thumbprint 'thumb'", captured.CommandText!, StringComparison.Ordinal); + Assert.Contains("-Include @('*.ps1', '*.psd1')", captured.CommandText!, StringComparison.Ordinal); + Assert.True(result.Succeeded); + } + + private sealed class StubPowerShellRunner : IPowerShellRunner + { + private readonly Func _execute; + + public StubPowerShellRunner(Func execute) + { + _execute = execute; + } + + public PowerShellRunResult Run(PowerShellRunRequest request) + => _execute(request); + } +} diff --git a/PowerForge.Tests/DotNetNuGetClientTests.cs b/PowerForge.Tests/DotNetNuGetClientTests.cs new file mode 100644 index 00000000..f3972b53 --- /dev/null +++ b/PowerForge.Tests/DotNetNuGetClientTests.cs @@ -0,0 +1,95 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class DotNetNuGetClientTests +{ + [Fact] + public async Task PushPackageAsync_UsesResponseFileAndCleansItUp() + { + ProcessRunRequest? captured = null; + string? responseFilePath = null; + string? responseFileContent = null; + var processRunner = new StubProcessRunner(request => { + captured = request; + responseFilePath = request.Arguments.Single()[1..]; + responseFileContent = File.ReadAllText(responseFilePath); + return new ProcessRunResult(0, "ok", string.Empty, request.FileName, TimeSpan.Zero, timedOut: false); + }); + var runtimeDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))).FullName; + var client = new DotNetNuGetClient(processRunner, runtimeDirectoryRoot: runtimeDirectory); + + try + { + var result = await client.PushPackageAsync(new DotNetNuGetPushRequest( + packagePath: @"C:\repo\Artifacts\Test.1.0.0.nupkg", + apiKey: "secret", + source: "https://api.nuget.org/v3/index.json")); + + Assert.NotNull(captured); + Assert.Equal("dotnet", captured!.FileName); + Assert.Single(captured.Arguments); + Assert.StartsWith("@", captured.Arguments[0], StringComparison.Ordinal); + Assert.NotNull(responseFileContent); + Assert.Contains("nuget", responseFileContent!, StringComparison.Ordinal); + Assert.Contains("push", responseFileContent!, StringComparison.Ordinal); + Assert.Contains("--skip-duplicate", responseFileContent!, StringComparison.Ordinal); + Assert.True(result.Succeeded); + Assert.NotNull(responseFilePath); + Assert.False(File.Exists(responseFilePath!)); + } + finally + { + try { Directory.Delete(runtimeDirectory, recursive: true); } catch { } + } + } + + [Fact] + public async Task SignPackageAsync_BuildsStructuredArguments() + { + ProcessRunRequest? captured = null; + var processRunner = new StubProcessRunner(request => { + captured = request; + return new ProcessRunResult(0, string.Empty, string.Empty, request.FileName, TimeSpan.Zero, timedOut: false); + }); + var client = new DotNetNuGetClient(processRunner); + + var result = await client.SignPackageAsync(new DotNetNuGetSignRequest( + packagePath: @"C:\repo\Artifacts\Test.1.0.0.nupkg", + certificateFingerprint: "ABC123", + certificateStoreLocation: "CurrentUser", + timeStampServer: "http://timestamp.digicert.com")); + + Assert.NotNull(captured); + Assert.Equal( + [ + "nuget", + "sign", + @"C:\repo\Artifacts\Test.1.0.0.nupkg", + "--certificate-fingerprint", + "ABC123", + "--certificate-store-location", + "CurrentUser", + "--certificate-store-name", + "My", + "--timestamper", + "http://timestamp.digicert.com", + "--overwrite" + ], + captured!.Arguments); + Assert.True(result.Succeeded); + } + + private sealed class StubProcessRunner : IProcessRunner + { + private readonly Func _execute; + + public StubProcessRunner(Func execute) + { + _execute = execute; + } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(_execute(request)); + } +} diff --git a/PowerForge.Tests/GitClientTests.cs b/PowerForge.Tests/GitClientTests.cs new file mode 100644 index 00000000..28c1de84 --- /dev/null +++ b/PowerForge.Tests/GitClientTests.cs @@ -0,0 +1,88 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class GitClientTests +{ + [Fact] + public async Task GetStatusAsync_ParsesBranchAheadBehindAndChangeCounts() + { + const string output = """ +# branch.head codex/release-flow +# branch.upstream origin/codex/release-flow +# branch.ab +2 -1 +1 .M N... 100644 100644 100644 123456 123456 file.cs +? notes.txt +"""; + + var runner = new StubProcessRunner(_ => new ProcessRunResult( + exitCode: 0, + stdOut: output, + stdErr: string.Empty, + executable: "git", + duration: TimeSpan.FromSeconds(1), + timedOut: false)); + var client = new GitClient(runner); + + var snapshot = await client.GetStatusAsync(@"C:\repo"); + + Assert.True(snapshot.IsGitRepository); + Assert.Equal("codex/release-flow", snapshot.BranchName); + Assert.Equal("origin/codex/release-flow", snapshot.UpstreamBranch); + Assert.Equal(2, snapshot.AheadCount); + Assert.Equal(1, snapshot.BehindCount); + Assert.Equal(1, snapshot.TrackedChangeCount); + Assert.Equal(1, snapshot.UntrackedChangeCount); + } + + [Fact] + public async Task CreateBranchAsync_BuildsTypedGitArguments() + { + ProcessRunRequest? captured = null; + var runner = new StubProcessRunner(request => { + captured = request; + return new ProcessRunResult(0, string.Empty, string.Empty, "git", TimeSpan.Zero, timedOut: false); + }); + var client = new GitClient(runner); + + var result = await client.CreateBranchAsync(@"C:\repo", "codex/pspublishmodule-release-flow"); + + Assert.NotNull(captured); + Assert.Equal("git", captured!.FileName); + Assert.Equal(@"C:\repo", captured.WorkingDirectory); + Assert.Equal(["switch", "-c", "codex/pspublishmodule-release-flow"], captured.Arguments); + Assert.Equal("git switch -c codex/pspublishmodule-release-flow", result.DisplayCommand); + Assert.True(result.Succeeded); + } + + [Fact] + public async Task GetRemoteUrlAsync_BuildsTypedGitArguments() + { + ProcessRunRequest? captured = null; + var runner = new StubProcessRunner(request => { + captured = request; + return new ProcessRunResult(0, "https://github.com/EvotecIT/PSPublishModule.git", string.Empty, "git", TimeSpan.Zero, timedOut: false); + }); + var client = new GitClient(runner); + + var result = await client.GetRemoteUrlAsync(@"C:\repo"); + + Assert.NotNull(captured); + Assert.Equal(["remote", "get-url", "origin"], captured!.Arguments); + Assert.Equal("git remote get-url origin", result.DisplayCommand); + Assert.True(result.Succeeded); + } + + private sealed class StubProcessRunner : IProcessRunner + { + private readonly Func _execute; + + public StubProcessRunner(Func execute) + { + _execute = execute; + } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(_execute(request)); + } +} diff --git a/PowerForge.Tests/GitHubActionsCacheCleanupServiceTests.cs b/PowerForge.Tests/GitHubActionsCacheCleanupServiceTests.cs new file mode 100644 index 00000000..e5394c9e --- /dev/null +++ b/PowerForge.Tests/GitHubActionsCacheCleanupServiceTests.cs @@ -0,0 +1,219 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace PowerForge.Tests; + +public sealed class GitHubActionsCacheCleanupServiceTests +{ + [Fact] + public void Prune_DryRun_UsesAgeThresholdAndUsageSnapshot() + { + var caches = new[] + { + Cache(id: 1, key: "ubuntu-nuget", daysAgo: 30), + Cache(id: 2, key: "ubuntu-nuget", daysAgo: 10), + Cache(id: 3, key: "ubuntu-nuget", daysAgo: 1), + Cache(id: 4, key: "windows-nuget", daysAgo: 20), + Cache(id: 5, key: "keep-me", daysAgo: 40) + }; + + var handler = new FakeGitHubCachesHandler(caches, activeCachesCount: 48, activeCachesBytes: 9_950_000_000L); + using var client = new HttpClient(handler); + var service = new GitHubActionsCacheCleanupService(new NullLogger(), client); + + var result = service.Prune(new GitHubActionsCacheCleanupSpec + { + Repository = "EvotecIT/PSPublishModule", + Token = "test-token", + ExcludeKeys = new[] { "keep-me" }, + KeepLatestPerKey = 1, + MaxAgeDays = 7, + DryRun = true + }); + + Assert.True(result.Success); + Assert.True(result.DryRun); + Assert.NotNull(result.UsageBefore); + Assert.Equal(48, result.UsageBefore!.ActiveCachesCount); + Assert.Equal(5, result.ScannedCaches); + Assert.Equal(4, result.MatchedCaches); + Assert.Equal(2, result.PlannedDeletes); + Assert.Equal(new long[] { 1, 2 }, result.Planned.Select(c => c.Id).OrderBy(v => v).ToArray()); + Assert.DoesNotContain(handler.DeletedCacheIds, _ => true); + } + + [Fact] + public void Prune_Apply_DeletesCachesAndReportsFailures() + { + var caches = new[] + { + Cache(id: 11, key: "ubuntu-node", daysAgo: 25), + Cache(id: 12, key: "ubuntu-node", daysAgo: 18) + }; + + var handler = new FakeGitHubCachesHandler(caches, failDeleteIds: new[] { 12L }); + using var client = new HttpClient(handler); + var service = new GitHubActionsCacheCleanupService(new NullLogger(), client); + + var result = service.Prune(new GitHubActionsCacheCleanupSpec + { + Repository = "EvotecIT/HtmlForgeX", + Token = "test-token", + KeepLatestPerKey = 0, + MaxAgeDays = null, + DryRun = false, + FailOnDeleteError = true + }); + + Assert.False(result.Success); + Assert.Equal(2, result.PlannedDeletes); + Assert.Equal(1, result.DeletedCaches); + Assert.Equal(1, result.FailedDeletes); + Assert.Contains(11L, handler.DeletedCacheIds); + Assert.Contains(12L, handler.DeletedCacheIds); + Assert.Single(result.Failed); + Assert.Equal(12L, result.Failed[0].Id); + Assert.NotNull(result.Failed[0].DeleteError); + } + + [Fact] + public void Prune_IncludePattern_FiltersKeys() + { + var caches = new[] + { + Cache(id: 21, key: "ubuntu-nuget", daysAgo: 15), + Cache(id: 22, key: "windows-nuget", daysAgo: 15), + Cache(id: 23, key: "ubuntu-pnpm", daysAgo: 15) + }; + + var handler = new FakeGitHubCachesHandler(caches); + using var client = new HttpClient(handler); + var service = new GitHubActionsCacheCleanupService(new NullLogger(), client); + + var result = service.Prune(new GitHubActionsCacheCleanupSpec + { + Repository = "EvotecIT/CodeGlyphX", + Token = "test-token", + IncludeKeys = new[] { "ubuntu-*" }, + KeepLatestPerKey = 0, + MaxAgeDays = null, + DryRun = true + }); + + Assert.Equal(new long[] { 21, 23 }, result.Planned.Select(c => c.Id).OrderBy(v => v).ToArray()); + } + + private static FakeCache Cache(long id, string key, int daysAgo) + { + var timestamp = DateTimeOffset.UtcNow.AddDays(-daysAgo); + return new FakeCache + { + Id = id, + Key = key, + Ref = "refs/heads/main", + Version = "v1", + SizeInBytes = 1024 + id, + CreatedAt = timestamp, + LastAccessedAt = timestamp + }; + } + + private sealed class FakeGitHubCachesHandler : HttpMessageHandler + { + private readonly FakeCache[] _caches; + private readonly HashSet _failDeleteIds; + private readonly int _activeCachesCount; + private readonly long _activeCachesBytes; + + public List DeletedCacheIds { get; } = new(); + + public FakeGitHubCachesHandler( + FakeCache[] caches, + int activeCachesCount = 0, + long activeCachesBytes = 0, + IEnumerable? failDeleteIds = null) + { + _caches = caches ?? Array.Empty(); + _activeCachesCount = activeCachesCount == 0 ? _caches.Length : activeCachesCount; + _activeCachesBytes = activeCachesBytes == 0 ? _caches.Sum(c => c.SizeInBytes) : activeCachesBytes; + _failDeleteIds = failDeleteIds is null ? new HashSet() : new HashSet(failDeleteIds); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + + if (request.Method == HttpMethod.Get && path.Contains("/actions/cache/usage", StringComparison.OrdinalIgnoreCase)) + { + var usage = JsonSerializer.Serialize(new + { + active_caches_count = _activeCachesCount, + active_caches_size_in_bytes = _activeCachesBytes + }); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(usage, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Get && path.Contains("/actions/caches", StringComparison.OrdinalIgnoreCase)) + { + var payload = JsonSerializer.Serialize(new + { + total_count = _caches.Length, + actions_caches = _caches.Select(c => new + { + id = c.Id, + key = c.Key, + @ref = c.Ref, + version = c.Version, + size_in_bytes = c.SizeInBytes, + created_at = c.CreatedAt.ToString("O"), + last_accessed_at = c.LastAccessedAt.ToString("O") + }).ToArray() + }); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Delete && path.Contains("/actions/caches/", StringComparison.OrdinalIgnoreCase)) + { + var idText = request.RequestUri?.Segments.LastOrDefault()?.Trim('/'); + _ = long.TryParse(idText, out var cacheId); + DeletedCacheIds.Add(cacheId); + + if (_failDeleteIds.Contains(cacheId)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{\"message\":\"delete failed\"}", Encoding.UTF8, "application/json") + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }); + } + } + + private sealed class FakeCache + { + public long Id { get; set; } + public string Key { get; set; } = string.Empty; + public string Ref { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public long SizeInBytes { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastAccessedAt { get; set; } + } +} diff --git a/PowerForge.Tests/GitHubHousekeepingServiceTests.cs b/PowerForge.Tests/GitHubHousekeepingServiceTests.cs new file mode 100644 index 00000000..aed0d820 --- /dev/null +++ b/PowerForge.Tests/GitHubHousekeepingServiceTests.cs @@ -0,0 +1,236 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace PowerForge.Tests; + +public sealed class GitHubHousekeepingServiceTests +{ + [Fact] + public void Run_DryRun_AggregatesConfiguredSections() + { + var artifactHandler = new FakeGitHubArtifactsHandler(new[] + { + Artifact(id: 1, name: "test-results", daysAgo: 20), + Artifact(id: 2, name: "test-results", daysAgo: 10) + }); + var cacheHandler = new FakeGitHubCachesHandler(new[] + { + Cache(id: 11, key: "ubuntu-nuget", daysAgo: 20), + Cache(id: 12, key: "ubuntu-nuget", daysAgo: 5) + }); + + using var artifactClient = new HttpClient(artifactHandler); + using var cacheClient = new HttpClient(cacheHandler); + var service = new GitHubHousekeepingService( + new NullLogger(), + new GitHubArtifactCleanupService(new NullLogger(), artifactClient), + new GitHubActionsCacheCleanupService(new NullLogger(), cacheClient), + new RunnerHousekeepingService(new NullLogger())); + + var result = service.Run(new GitHubHousekeepingSpec + { + Repository = "EvotecIT/PSPublishModule", + Token = "test-token", + DryRun = true, + Runner = new GitHubHousekeepingRunnerSpec { Enabled = false }, + Artifacts = new GitHubHousekeepingArtifactSpec + { + Enabled = true, + KeepLatestPerName = 0, + MaxAgeDays = null + }, + Caches = new GitHubHousekeepingCacheSpec + { + Enabled = true, + KeepLatestPerKey = 0, + MaxAgeDays = null + } + }); + + Assert.True(result.Success); + Assert.Equal(new[] { "artifacts", "caches" }, result.RequestedSections); + Assert.Equal(new[] { "artifacts", "caches" }, result.CompletedSections); + Assert.Empty(result.FailedSections); + Assert.NotNull(result.Artifacts); + Assert.NotNull(result.Caches); + Assert.Equal(2, result.Artifacts!.PlannedDeletes); + Assert.Equal(2, result.Caches!.PlannedDeletes); + } + + [Fact] + public void Run_RemoteCleanupWithoutToken_FailsGracefully() + { + var service = new GitHubHousekeepingService(new NullLogger()); + + var result = service.Run(new GitHubHousekeepingSpec + { + Repository = "EvotecIT/OfficeIMO", + Token = string.Empty, + DryRun = true, + Artifacts = new GitHubHousekeepingArtifactSpec { Enabled = true }, + Caches = new GitHubHousekeepingCacheSpec { Enabled = false }, + Runner = new GitHubHousekeepingRunnerSpec { Enabled = false } + }); + + Assert.False(result.Success); + Assert.Contains("artifacts", result.FailedSections); + Assert.Contains("token", result.Message ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + private static FakeArtifact Artifact(long id, string name, int daysAgo) + { + var timestamp = DateTimeOffset.UtcNow.AddDays(-daysAgo); + return new FakeArtifact + { + Id = id, + Name = name, + SizeInBytes = 1024 + id, + CreatedAt = timestamp, + UpdatedAt = timestamp + }; + } + + private static FakeCache Cache(long id, string key, int daysAgo) + { + var timestamp = DateTimeOffset.UtcNow.AddDays(-daysAgo); + return new FakeCache + { + Id = id, + Key = key, + Ref = "refs/heads/main", + Version = "v1", + SizeInBytes = 1024 + id, + CreatedAt = timestamp, + LastAccessedAt = timestamp + }; + } + + private sealed class FakeGitHubArtifactsHandler : HttpMessageHandler + { + private readonly FakeArtifact[] _artifacts; + + public FakeGitHubArtifactsHandler(FakeArtifact[] artifacts) + { + _artifacts = artifacts ?? Array.Empty(); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Get && + request.RequestUri is not null && + request.RequestUri.AbsolutePath.Contains("/actions/artifacts", StringComparison.OrdinalIgnoreCase)) + { + var payload = new + { + total_count = _artifacts.Length, + artifacts = _artifacts.Select(a => new + { + id = a.Id, + name = a.Name, + size_in_bytes = a.SizeInBytes, + expired = false, + created_at = a.CreatedAt.ToString("O"), + updated_at = a.UpdatedAt.ToString("O"), + workflow_run = new { id = 1000 + a.Id } + }).ToArray() + }; + + var json = JsonSerializer.Serialize(payload); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Delete) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }); + } + } + + private sealed class FakeGitHubCachesHandler : HttpMessageHandler + { + private readonly FakeCache[] _caches; + + public FakeGitHubCachesHandler(FakeCache[] caches) + { + _caches = caches ?? Array.Empty(); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + + if (request.Method == HttpMethod.Get && path.Contains("/actions/cache/usage", StringComparison.OrdinalIgnoreCase)) + { + var usage = JsonSerializer.Serialize(new + { + active_caches_count = _caches.Length, + active_caches_size_in_bytes = _caches.Sum(c => c.SizeInBytes) + }); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(usage, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Get && path.Contains("/actions/caches", StringComparison.OrdinalIgnoreCase)) + { + var payload = JsonSerializer.Serialize(new + { + total_count = _caches.Length, + actions_caches = _caches.Select(c => new + { + id = c.Id, + key = c.Key, + @ref = c.Ref, + version = c.Version, + size_in_bytes = c.SizeInBytes, + created_at = c.CreatedAt.ToString("O"), + last_accessed_at = c.LastAccessedAt.ToString("O") + }).ToArray() + }); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Delete) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }); + } + } + + private sealed class FakeArtifact + { + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public long SizeInBytes { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + } + + private sealed class FakeCache + { + public long Id { get; set; } + public string Key { get; set; } = string.Empty; + public string Ref { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public long SizeInBytes { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastAccessedAt { get; set; } + } +} diff --git a/PowerForge.Tests/ModuleBuildHostServiceTests.cs b/PowerForge.Tests/ModuleBuildHostServiceTests.cs new file mode 100644 index 00000000..2ba92077 --- /dev/null +++ b/PowerForge.Tests/ModuleBuildHostServiceTests.cs @@ -0,0 +1,69 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class ModuleBuildHostServiceTests +{ + [Fact] + public async Task ExportPipelineJsonAsync_UsesSharedModuleWrapperAndWorkingDirectory() + { + PowerShellRunRequest? captured = null; + var runner = new StubPowerShellRunner(request => { + captured = request; + return new PowerShellRunResult(0, "ok", string.Empty, "pwsh"); + }); + var service = new ModuleBuildHostService(runner); + + var result = await service.ExportPipelineJsonAsync(new ModuleBuildHostExportRequest { + RepositoryRoot = @"C:\repo", + ScriptPath = @"C:\repo\Build\Build-Module.ps1", + ModulePath = @"C:\repo\Module\PSPublishModule.psd1", + OutputPath = @"C:\repo\artifacts\powerforge.json" + }); + + Assert.NotNull(captured); + Assert.Equal(PowerShellInvocationMode.Command, captured!.InvocationMode); + Assert.Equal(@"C:\repo", captured.WorkingDirectory); + Assert.Contains("JsonOnly = $true", captured.CommandText!, StringComparison.Ordinal); + Assert.Contains("JsonPath = $targetJson", captured.CommandText!, StringComparison.Ordinal); + Assert.Contains(@". 'C:\repo\Build\Build-Module.ps1'", captured.CommandText!, StringComparison.Ordinal); + Assert.True(result.Succeeded); + } + + [Fact] + public async Task ExecuteBuildAsync_UsesSharedSigningOverrideWrapper() + { + PowerShellRunRequest? captured = null; + var runner = new StubPowerShellRunner(request => { + captured = request; + return new PowerShellRunResult(0, "ok", string.Empty, "pwsh"); + }); + var service = new ModuleBuildHostService(runner); + + var result = await service.ExecuteBuildAsync(new ModuleBuildHostBuildRequest { + RepositoryRoot = @"C:\repo", + ScriptPath = @"C:\repo\Build\Build-Module.ps1", + ModulePath = @"C:\repo\Module\PSPublishModule.psd1" + }); + + Assert.NotNull(captured); + Assert.Equal(PowerShellInvocationMode.Command, captured!.InvocationMode); + Assert.Contains("function New-ConfigurationBuild", captured.CommandText!, StringComparison.Ordinal); + Assert.Contains("$params['SignModule'] = $false", captured.CommandText!, StringComparison.Ordinal); + Assert.Contains("-SignModule:$false", captured.CommandText!, StringComparison.Ordinal); + Assert.True(result.Succeeded); + } + + private sealed class StubPowerShellRunner : IPowerShellRunner + { + private readonly Func _execute; + + public StubPowerShellRunner(Func execute) + { + _execute = execute; + } + + public PowerShellRunResult Run(PowerShellRunRequest request) + => _execute(request); + } +} diff --git a/PowerForge.Tests/ModuleDependencyInstallerExactVersionTests.cs b/PowerForge.Tests/ModuleDependencyInstallerExactVersionTests.cs index 28fb0599..ac542450 100644 --- a/PowerForge.Tests/ModuleDependencyInstallerExactVersionTests.cs +++ b/PowerForge.Tests/ModuleDependencyInstallerExactVersionTests.cs @@ -137,7 +137,8 @@ public StubPowerShellRunner( public PowerShellRunResult Run(PowerShellRunRequest request) { - var script = File.ReadAllText(request.ScriptPath); + Assert.NotNull(request.ScriptPath); + var script = File.ReadAllText(request.ScriptPath!); if (script.Contains(InstalledVersionsMarker, StringComparison.Ordinal)) { diff --git a/PowerForge.Tests/ModuleManifestMetadataReaderTests.cs b/PowerForge.Tests/ModuleManifestMetadataReaderTests.cs new file mode 100644 index 00000000..6f0b6e0c --- /dev/null +++ b/PowerForge.Tests/ModuleManifestMetadataReaderTests.cs @@ -0,0 +1,39 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class ModuleManifestMetadataReaderTests +{ + [Fact] + public void Read_ResolvesVersionRootModuleAndPrerelease() + { + var directory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + var manifestPath = Path.Combine(directory.FullName, "PSPublishModule.psd1"); + File.WriteAllText( + manifestPath, + """ + @{ + RootModule = 'PSPublishModule.psm1' + ModuleVersion = '2.1.0' + PrivateData = @{ + PSData = @{ + Prerelease = 'preview3' + } + } + } + """); + + try + { + var metadata = new ModuleManifestMetadataReader().Read(manifestPath); + + Assert.Equal("PSPublishModule", metadata.ModuleName); + Assert.Equal("2.1.0", metadata.ModuleVersion); + Assert.Equal("preview3", metadata.PreRelease); + } + finally + { + try { Directory.Delete(directory.FullName, recursive: true); } catch { } + } + } +} diff --git a/PowerForge.Tests/ModulePipelineRequiredModulesResolutionTests.cs b/PowerForge.Tests/ModulePipelineRequiredModulesResolutionTests.cs index a78934e0..4530a75b 100644 --- a/PowerForge.Tests/ModulePipelineRequiredModulesResolutionTests.cs +++ b/PowerForge.Tests/ModulePipelineRequiredModulesResolutionTests.cs @@ -167,7 +167,8 @@ public StubPowerShellRunner( public PowerShellRunResult Run(PowerShellRunRequest request) { - var script = File.ReadAllText(request.ScriptPath); + Assert.NotNull(request.ScriptPath); + var script = File.ReadAllText(request.ScriptPath!); if (script.Contains(InstalledModuleInfoMarker, StringComparison.Ordinal)) { diff --git a/PowerForge.Tests/ModulePublishConfigurationReaderTests.cs b/PowerForge.Tests/ModulePublishConfigurationReaderTests.cs new file mode 100644 index 00000000..a18310d2 --- /dev/null +++ b/PowerForge.Tests/ModulePublishConfigurationReaderTests.cs @@ -0,0 +1,91 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class ModulePublishConfigurationReaderTests +{ + [Fact] + public void ReadFromJson_ResolvesRepositoryAndGitHubPublishSegments() + { + const string json = """ + { + "Build": { + "Name": "PSPublishModule", + "SourcePath": "Module/PSPublishModule", + "Version": "2.0.0" + }, + "Segments": [ + { + "Type": "GalleryNuget", + "Configuration": { + "Destination": "PowerShellGallery", + "Tool": "PSResourceGet", + "ApiKey": "gallery-key", + "Enabled": true, + "RepositoryName": "PSGallery", + "Repository": { + "Name": "PSGallery", + "Uri": "https://www.powershellgallery.com/api/v2", + "EnsureRegistered": true, + "Trusted": true, + "Credential": { + "UserName": "user", + "Secret": "secret" + } + } + } + }, + { + "Type": "GitHubNuget", + "Configuration": { + "Destination": "GitHub", + "ApiKey": "token", + "Enabled": true, + "UserName": "EvotecIT", + "RepositoryName": "PSPublishModule", + "GenerateReleaseNotes": true, + "OverwriteTagName": "{TagModuleVersionWithPreRelease}" + } + } + ] + } + """; + + var configs = new ModulePublishConfigurationReader().ReadFromJson(json); + + Assert.Equal(2, configs.Count); + + var repositoryPublish = configs[0]; + Assert.Equal(PublishDestination.PowerShellGallery, repositoryPublish.Destination); + Assert.Equal(PublishTool.PSResourceGet, repositoryPublish.Tool); + Assert.Equal("gallery-key", repositoryPublish.ApiKey); + Assert.Equal("PSGallery", repositoryPublish.RepositoryName); + Assert.NotNull(repositoryPublish.Repository); + Assert.Equal("PSGallery", repositoryPublish.Repository!.Name); + Assert.Equal("https://www.powershellgallery.com/api/v2", repositoryPublish.Repository.Uri); + Assert.NotNull(repositoryPublish.Repository.Credential); + Assert.Equal("user", repositoryPublish.Repository.Credential!.UserName); + Assert.Equal("secret", repositoryPublish.Repository.Credential.Secret); + + var gitHubPublish = configs[1]; + Assert.Equal(PublishDestination.GitHub, gitHubPublish.Destination); + Assert.Equal("EvotecIT", gitHubPublish.UserName); + Assert.Equal("PSPublishModule", gitHubPublish.RepositoryName); + Assert.True(gitHubPublish.GenerateReleaseNotes); + Assert.Equal("{TagModuleVersionWithPreRelease}", gitHubPublish.OverwriteTagName); + } + + [Fact] + public void BuildTag_UsesSharedModulePublishTokenRules() + { + var tag = new ModulePublishTagBuilder().BuildTag( + new PublishConfiguration { + OverwriteTagName = "{TagModuleVersionWithPreRelease}" + }, + moduleName: "PSPublishModule", + resolvedVersion: "2.0.0", + preRelease: "preview1"); + + Assert.Equal("v2.0.0-preview1", tag); + } +} diff --git a/PowerForge.Tests/PowerShellRepositoryResolverTests.cs b/PowerForge.Tests/PowerShellRepositoryResolverTests.cs new file mode 100644 index 00000000..f1a75670 --- /dev/null +++ b/PowerForge.Tests/PowerShellRepositoryResolverTests.cs @@ -0,0 +1,54 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class PowerShellRepositoryResolverTests +{ + [Fact] + public async Task ResolveAsync_UsesSharedPowerShellLookupScript() + { + PowerShellRunRequest? captured = null; + var resolver = new PowerShellRepositoryResolver(new StubPowerShellRunner(request => { + captured = request; + return new PowerShellRunResult( + 0, + "{\"Name\":\"PrivateGallery\",\"SourceUri\":\"https://packages.contoso.test/powershell/v3/index.json\",\"PublishUri\":\"https://packages.contoso.test/powershell/api/v2/package\"}", + string.Empty, + "pwsh"); + })); + + var result = await resolver.ResolveAsync(@"C:\repo", "PrivateGallery"); + + Assert.NotNull(captured); + Assert.Equal(PowerShellInvocationMode.Command, captured!.InvocationMode); + Assert.Equal(@"C:\repo", captured.WorkingDirectory); + Assert.Contains("Get-PSResourceRepository", captured.CommandText!, StringComparison.Ordinal); + Assert.NotNull(result); + Assert.Equal("PrivateGallery", result!.Name); + Assert.Equal("https://packages.contoso.test/powershell/v3/index.json", result.SourceUri); + } + + [Fact] + public async Task ResolveAsync_PassesThroughAbsoluteUri() + { + var resolver = new PowerShellRepositoryResolver(new StubPowerShellRunner(_ => throw new InvalidOperationException("PowerShell should not be used for direct URIs."))); + + var result = await resolver.ResolveAsync(@"C:\repo", "https://packages.contoso.test/powershell/v3/index.json"); + + Assert.NotNull(result); + Assert.Equal("https://packages.contoso.test/powershell/v3/index.json", result!.SourceUri); + } + + private sealed class StubPowerShellRunner : IPowerShellRunner + { + private readonly Func _execute; + + public StubPowerShellRunner(Func execute) + { + _execute = execute; + } + + public PowerShellRunResult Run(PowerShellRunRequest request) + => _execute(request); + } +} diff --git a/PowerForge.Tests/PowerShellRunnerTests.cs b/PowerForge.Tests/PowerShellRunnerTests.cs new file mode 100644 index 00000000..3c605655 --- /dev/null +++ b/PowerForge.Tests/PowerShellRunnerTests.cs @@ -0,0 +1,84 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class PowerShellRunnerTests +{ + [Fact] + public void Run_CommandRequest_UsesStructuredProcessRunnerWithCommandInvocation() + { + var executablePath = CreateStubExecutablePath(); + ProcessRunRequest? captured = null; + var processRunner = new StubProcessRunner(request => { + captured = request; + return new ProcessRunResult(0, "ok", string.Empty, request.FileName, TimeSpan.FromSeconds(1), timedOut: false); + }); + var runner = new PowerShellRunner(processRunner); + var environmentVariables = new Dictionary { + ["PF_TEST"] = "1" + }; + + var result = runner.Run(PowerShellRunRequest.ForCommand( + commandText: "Get-ChildItem", + timeout: TimeSpan.FromMinutes(1), + preferPwsh: true, + workingDirectory: @"C:\repo", + environmentVariables: environmentVariables, + executableOverride: executablePath)); + + Assert.NotNull(captured); + Assert.Equal(executablePath, captured!.FileName); + Assert.Equal(@"C:\repo", captured.WorkingDirectory); + Assert.Equal(environmentVariables, captured.EnvironmentVariables); + Assert.Equal(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "Get-ChildItem"], captured.Arguments); + Assert.Equal(executablePath, result.Executable); + Assert.Equal(0, result.ExitCode); + } + + [Fact] + public void Run_FileRequest_UsesStructuredProcessRunnerWithFileInvocation() + { + var executablePath = CreateStubExecutablePath(); + ProcessRunRequest? captured = null; + var processRunner = new StubProcessRunner(request => { + captured = request; + return new ProcessRunResult(0, string.Empty, string.Empty, request.FileName, TimeSpan.Zero, timedOut: false); + }); + var runner = new PowerShellRunner(processRunner); + + _ = runner.Run(new PowerShellRunRequest( + scriptPath: @"C:\repo\Build\Build-Module.ps1", + arguments: ["-Configuration", "Release"], + timeout: TimeSpan.FromMinutes(2), + preferPwsh: true, + workingDirectory: @"C:\repo", + executableOverride: executablePath)); + + Assert.NotNull(captured); + Assert.Equal( + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", @"C:\repo\Build\Build-Module.ps1", "-Configuration", "Release"], + captured!.Arguments); + } + + private static string CreateStubExecutablePath() + { + var path = Path.Combine(Path.GetTempPath(), "powerforge-pwsh-stub.exe"); + if (!File.Exists(path)) + File.WriteAllText(path, string.Empty); + + return path; + } + + private sealed class StubProcessRunner : IProcessRunner + { + private readonly Func _execute; + + public StubProcessRunner(Func execute) + { + _execute = execute; + } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(_execute(request)); + } +} diff --git a/PowerForge.Tests/ProjectBuildCommandHostServiceTests.cs b/PowerForge.Tests/ProjectBuildCommandHostServiceTests.cs new file mode 100644 index 00000000..4d79ca4b --- /dev/null +++ b/PowerForge.Tests/ProjectBuildCommandHostServiceTests.cs @@ -0,0 +1,62 @@ +namespace PowerForge.Tests; + +public sealed class ProjectBuildCommandHostServiceTests +{ + [Fact] + public async Task GeneratePlanAsync_UsesSharedInvokeProjectBuildPlanCommand() + { + PowerShellRunRequest? captured = null; + var service = new ProjectBuildCommandHostService(new StubPowerShellRunner(request => { + captured = request; + return new PowerShellRunResult(0, "planned", string.Empty, "pwsh"); + })); + + var result = await service.GeneratePlanAsync(new ProjectBuildCommandPlanRequest { + RepositoryRoot = @"C:\Repo", + PlanOutputPath = @"C:\Repo\plan.json", + ConfigPath = @"C:\Repo\Build\project.build.json", + ModulePath = @"C:\Repo\Module\PSPublishModule.psd1" + }); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.Equal(PowerShellInvocationMode.Command, captured!.InvocationMode); + Assert.Contains("Invoke-ProjectBuild -Plan:$true", captured.CommandText, StringComparison.Ordinal); + Assert.Contains("-PlanPath 'C:\\Repo\\plan.json'", captured.CommandText, StringComparison.Ordinal); + Assert.Contains("-ConfigPath 'C:\\Repo\\Build\\project.build.json'", captured.CommandText, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteBuildAsync_UsesSharedInvokeProjectBuildBuildCommand() + { + PowerShellRunRequest? captured = null; + var service = new ProjectBuildCommandHostService(new StubPowerShellRunner(request => { + captured = request; + return new PowerShellRunResult(0, "built", string.Empty, "pwsh"); + })); + + var result = await service.ExecuteBuildAsync(new ProjectBuildCommandBuildRequest { + RepositoryRoot = @"C:\Repo", + ConfigPath = @"C:\Repo\Build\project.build.json", + ModulePath = @"C:\Repo\Module\PSPublishModule.psd1" + }); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.Contains("Invoke-ProjectBuild -Build:$true -PublishNuget:$false -PublishGitHub:$false -UpdateVersions:$false", captured!.CommandText, StringComparison.Ordinal); + Assert.Contains("-ConfigPath 'C:\\Repo\\Build\\project.build.json'", captured.CommandText, StringComparison.Ordinal); + } + + private sealed class StubPowerShellRunner : IPowerShellRunner + { + private readonly Func _execute; + + public StubPowerShellRunner(Func execute) + { + _execute = execute; + } + + public PowerShellRunResult Run(PowerShellRunRequest request) + => _execute(request); + } +} diff --git a/PowerForge.Tests/ProjectBuildHostServiceTests.cs b/PowerForge.Tests/ProjectBuildHostServiceTests.cs new file mode 100644 index 00000000..058e4d4c --- /dev/null +++ b/PowerForge.Tests/ProjectBuildHostServiceTests.cs @@ -0,0 +1,146 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class ProjectBuildHostServiceTests +{ + [Fact] + public void Execute_WritesPlanAndUsesRequestedActionOverrides() + { + using var scope = new TemporaryDirectoryScope(); + var configDirectory = scope.CreateDirectory("Repo"); + var configPath = Path.Combine(configDirectory, "project.build.json"); + var planPath = Path.Combine(configDirectory, "artifacts", "plan.json"); + File.WriteAllText( + configPath, + """ + { + "RootPath": ".", + "Build": true, + "PublishNuget": true, + "PublishGitHub": true + } + """); + + DotNetRepositoryReleaseSpec? captured = null; + var service = new ProjectBuildHostService( + new NullLogger(), + executeRelease: spec => + { + captured = spec; + return new DotNetRepositoryReleaseResult { Success = true, ResolvedVersion = "1.2.3" }; + }, + publishGitHub: null, + validateGitHubPreflight: null); + + var result = service.Execute(new ProjectBuildHostRequest { + ConfigPath = configPath, + PlanOutputPath = planPath, + ExecuteBuild = false, + PlanOnly = true, + UpdateVersions = false, + Build = false, + PublishNuget = false, + PublishGitHub = false + }); + + Assert.True(result.Success); + Assert.NotNull(captured); + Assert.True(captured!.WhatIf); + Assert.False(captured.Pack); + Assert.False(captured.Publish); + Assert.Equal(planPath, result.PlanOutputPath); + Assert.True(File.Exists(planPath)); + } + + [Fact] + public void Execute_RunsPlanThenBuildAndReturnsResolvedPaths() + { + using var scope = new TemporaryDirectoryScope(); + var configDirectory = scope.CreateDirectory("Repo"); + var outputDirectory = Path.Combine(configDirectory, "artifacts", "packages"); + var configPath = Path.Combine(configDirectory, "project.build.json"); + File.WriteAllText( + configPath, + """ + { + "RootPath": ".", + "OutputPath": "artifacts/packages", + "Build": true, + "PublishNuget": false, + "PublishGitHub": false + } + """); + + var callIndex = 0; + var service = new ProjectBuildHostService( + new NullLogger(), + executeRelease: spec => + { + callIndex++; + if (callIndex == 1) + { + Assert.True(spec.WhatIf); + return new DotNetRepositoryReleaseResult { Success = true }; + } + + Assert.False(spec.WhatIf); + Directory.CreateDirectory(outputDirectory); + var packagePath = Path.Combine(outputDirectory, "Example.1.0.0.nupkg"); + File.WriteAllText(packagePath, "pkg"); + return new DotNetRepositoryReleaseResult { + Success = true, + Projects = { + new DotNetRepositoryProjectResult { + ProjectName = "Example", + IsPackable = true, + NewVersion = "1.0.0", + Packages = { packagePath } + } + } + }; + }, + publishGitHub: null, + validateGitHubPreflight: null); + + var result = service.Execute(new ProjectBuildHostRequest { + ConfigPath = configPath, + ExecuteBuild = true, + PlanOnly = false, + UpdateVersions = false, + Build = true, + PublishNuget = false, + PublishGitHub = false + }); + + Assert.Equal(2, callIndex); + Assert.True(result.Success); + Assert.Equal(configDirectory, result.RootPath); + Assert.Equal(outputDirectory, result.OutputPath); + Assert.Single(result.Result.Release!.Projects); + Assert.Single(result.Result.Release.Projects[0].Packages); + } + + private sealed class TemporaryDirectoryScope : IDisposable + { + public TemporaryDirectoryScope() + { + RootPath = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(RootPath); + } + + public string RootPath { get; } + + public string CreateDirectory(string relativePath) + { + var path = Path.Combine(RootPath, relativePath); + Directory.CreateDirectory(path); + return path; + } + + public void Dispose() + { + try { Directory.Delete(RootPath, recursive: true); } catch { } + } + } +} diff --git a/PowerForge.Tests/ProjectBuildPublishHostServiceTests.cs b/PowerForge.Tests/ProjectBuildPublishHostServiceTests.cs new file mode 100644 index 00000000..40e3584a --- /dev/null +++ b/PowerForge.Tests/ProjectBuildPublishHostServiceTests.cs @@ -0,0 +1,131 @@ +using PowerForge; + +namespace PowerForge.Tests; + +public sealed class ProjectBuildPublishHostServiceTests +{ + [Fact] + public void LoadConfiguration_ResolvesSecretsAndSupportsRelaxedJson() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))).FullName; + var buildDirectory = Directory.CreateDirectory(Path.Combine(root, "Build")).FullName; + var apiKeyPath = Path.Combine(buildDirectory, "nuget.key"); + File.WriteAllText(apiKeyPath, "file-secret"); + var configPath = Path.Combine(buildDirectory, "project.build.json"); + File.WriteAllText( + configPath, + """ + { + // comments and trailing commas should be accepted by the shared host reader + "PublishNuget": true, + "PublishApiKeyFilePath": "nuget.key", + "PublishGitHub": true, + "GitHubAccessTokenEnvName": "PFGH_TOKEN", + "GitHubUsername": "EvotecIT", + "GitHubRepositoryName": "PSPublishModule", + "GitHubReleaseMode": "PerProject", + } + """); + + try + { + using var _ = new EnvironmentScope() + .Set("PFGH_TOKEN", "env-secret"); + + var configuration = new ProjectBuildPublishHostService().LoadConfiguration(configPath); + + Assert.True(configuration.PublishNuget); + Assert.True(configuration.PublishGitHub); + Assert.Equal("https://api.nuget.org/v3/index.json", configuration.PublishSource); + Assert.Equal("file-secret", configuration.PublishApiKey); + Assert.Equal("env-secret", configuration.GitHubToken); + Assert.Equal("EvotecIT", configuration.GitHubUsername); + Assert.Equal("PSPublishModule", configuration.GitHubRepositoryName); + Assert.Equal("PerProject", configuration.GitHubReleaseMode); + } + finally + { + try { Directory.Delete(root, recursive: true); } catch { } + } + } + + [Fact] + public void PublishGitHub_MapsHostConfigurationIntoSharedRequest() + { + ProjectBuildGitHubPublishRequest? captured = null; + var service = new ProjectBuildPublishHostService( + new NullLogger(), + request => { + captured = request; + return new ProjectBuildGitHubPublishSummary { + Success = true, + SummaryTag = "v1.2.3" + }; + }); + + var configuration = new ProjectBuildPublishHostConfiguration { + GitHubUsername = "EvotecIT", + GitHubRepositoryName = "PSPublishModule", + GitHubToken = "token", + GitHubReleaseMode = "PerProject", + GitHubIncludeProjectNameInTag = false, + GitHubIsPreRelease = true, + GitHubGenerateReleaseNotes = true, + GitHubReleaseName = "Release {Version}", + GitHubTagName = "v1.2.3", + GitHubTagTemplate = "{Project}-v{Version}", + GitHubPrimaryProject = "PSPublishModule", + GitHubTagConflictPolicy = "AppendUtcTimestamp" + }; + + var release = new DotNetRepositoryReleaseResult { + Success = true, + Projects = { + new DotNetRepositoryProjectResult { + ProjectName = "PSPublishModule", + IsPackable = true, + NewVersion = "1.2.3", + ReleaseZipPath = @"C:\Temp\PSPublishModule.1.2.3.zip" + } + } + }; + + var summary = service.PublishGitHub(configuration, release); + + Assert.True(summary.Success); + Assert.NotNull(captured); + Assert.Equal("EvotecIT", captured!.Owner); + Assert.Equal("PSPublishModule", captured.Repository); + Assert.Equal("token", captured.Token); + Assert.Same(release, captured.Release); + Assert.Equal("PerProject", captured.ReleaseMode); + Assert.False(captured.IncludeProjectNameInTag); + Assert.True(captured.IsPreRelease); + Assert.True(captured.GenerateReleaseNotes); + Assert.Equal("Release {Version}", captured.ReleaseName); + Assert.Equal("v1.2.3", captured.TagName); + Assert.Equal("{Project}-v{Version}", captured.TagTemplate); + Assert.Equal("PSPublishModule", captured.PrimaryProject); + Assert.Equal("AppendUtcTimestamp", captured.TagConflictPolicy); + } + + private sealed class EnvironmentScope : IDisposable + { + private readonly Dictionary _originalValues = new(StringComparer.OrdinalIgnoreCase); + + public EnvironmentScope Set(string name, string? value) + { + if (!_originalValues.ContainsKey(name)) + _originalValues[name] = Environment.GetEnvironmentVariable(name); + + Environment.SetEnvironmentVariable(name, value); + return this; + } + + public void Dispose() + { + foreach (var entry in _originalValues) + Environment.SetEnvironmentVariable(entry.Key, entry.Value); + } + } +} diff --git a/PowerForge.Tests/PublishVerificationHostServiceTests.cs b/PowerForge.Tests/PublishVerificationHostServiceTests.cs new file mode 100644 index 00000000..bf8c0475 --- /dev/null +++ b/PowerForge.Tests/PublishVerificationHostServiceTests.cs @@ -0,0 +1,175 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Http; + +namespace PowerForge.Tests; + +public sealed class PublishVerificationHostServiceTests +{ + [Fact] + public async Task VerifyAsync_NuGetFeed_VerifiesPackageAgainstResolvedFlatContainer() + { + using var packageScope = CreateTemporaryPackage("Contoso.ReleaseOps", "1.2.3"); + using var client = new HttpClient(new StubHttpMessageHandler(request => CreateResponse(request.RequestUri))); + using var service = new PublishVerificationHostService( + client, + new PowerShellRepositoryResolver(new StubPowerShellRunner(_ => new PowerShellRunResult(1, string.Empty, string.Empty, "pwsh"))), + new ModuleManifestMetadataReader()); + + var result = await service.VerifyAsync(new PublishVerificationRequest { + RootPath = packageScope.RootPath, + RepositoryName = "Contoso.ReleaseOps", + AdapterKind = "ProjectBuild", + TargetName = "Contoso.ReleaseOps.1.2.3.nupkg", + TargetKind = "NuGet", + Destination = "https://packages.contoso.test/nuget/v3/index.json", + SourcePath = packageScope.PackagePath + }); + + Assert.Equal(PublishVerificationStatus.Verified, result.Status); + Assert.Contains("packages.contoso.test", result.Summary); + } + + [Fact] + public async Task VerifyAsync_PowerShellRepository_UsesSharedRepositoryResolverAndManifestReader() + { + using var moduleScope = CreateTemporaryModule("ContosoModule", "2.5.0", "preview1"); + using var client = new HttpClient(new StubHttpMessageHandler(request => CreateResponse(request.RequestUri))); + using var service = new PublishVerificationHostService( + client, + new PowerShellRepositoryResolver(new StubPowerShellRunner(request => { + if (request.CommandText is not null && request.CommandText.Contains("Get-PSResourceRepository", StringComparison.Ordinal)) + { + return new PowerShellRunResult( + 0, + "{\"Name\":\"PrivateGallery\",\"SourceUri\":\"https://packages.contoso.test/powershell/v3/index.json\",\"PublishUri\":\"https://packages.contoso.test/powershell/api/v2/package\"}", + string.Empty, + "pwsh"); + } + + return new PowerShellRunResult(1, string.Empty, "Unexpected script", "pwsh"); + })), + new ModuleManifestMetadataReader()); + + var result = await service.VerifyAsync(new PublishVerificationRequest { + RootPath = moduleScope.RootPath, + RepositoryName = "ContosoModule", + AdapterKind = "ModuleBuild", + TargetName = "ContosoModule", + TargetKind = "PowerShellRepository", + Destination = "PrivateGallery", + SourcePath = moduleScope.ModuleRoot + }); + + Assert.Equal(PublishVerificationStatus.Verified, result.Status); + Assert.Contains("packages.contoso.test", result.Summary); + Assert.Contains("2.5.0-preview1", result.Summary); + } + + private static HttpResponseMessage CreateResponse(Uri? requestUri) + { + var path = requestUri?.AbsolutePath ?? string.Empty; + if (path.EndsWith("/index.json", StringComparison.OrdinalIgnoreCase)) + { + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent("{\"resources\":[{\"@id\":\"https://packages.contoso.test/v3-flatcontainer/\",\"@type\":\"PackageBaseAddress/3.0.0\"}]}") + }; + } + + if (path.Contains("/v3-flatcontainer/", StringComparison.OrdinalIgnoreCase)) + { + return new HttpResponseMessage(HttpStatusCode.OK); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + private static TemporaryPackageScope CreateTemporaryPackage(string packageId, string version) + { + var root = Path.Combine(Path.GetTempPath(), $"powerforge-package-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + var packagePath = Path.Combine(root, $"{packageId}.{version}.nupkg"); + using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); + var entry = archive.CreateEntry($"{packageId}.nuspec"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream); + writer.Write($""" + + + + {packageId} + {version} + + + """); + + return new TemporaryPackageScope(root, packagePath); + } + + private static TemporaryModuleScope CreateTemporaryModule(string moduleName, string version, string preRelease) + { + var root = Path.Combine(Path.GetTempPath(), $"powerforge-module-{Guid.NewGuid():N}"); + var moduleRoot = Path.Combine(root, moduleName); + Directory.CreateDirectory(moduleRoot); + File.WriteAllText( + Path.Combine(moduleRoot, $"{moduleName}.psd1"), + "@{" + Environment.NewLine + + $" RootModule = '{moduleName}.psm1'" + Environment.NewLine + + $" ModuleVersion = '{version}'" + Environment.NewLine + + " PrivateData = @{" + Environment.NewLine + + " PSData = @{" + Environment.NewLine + + $" Prerelease = '{preRelease}'" + Environment.NewLine + + " }" + Environment.NewLine + + " }" + Environment.NewLine + + "}" + Environment.NewLine); + File.WriteAllText(Path.Combine(moduleRoot, $"{moduleName}.psm1"), "function Test-PowerForge { }"); + return new TemporaryModuleScope(root, moduleRoot); + } + + private sealed class StubHttpMessageHandler(Func responseFactory) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(responseFactory(request)); + } + + private sealed class StubPowerShellRunner : IPowerShellRunner + { + private readonly Func _execute; + + public StubPowerShellRunner(Func execute) + { + _execute = execute; + } + + public PowerShellRunResult Run(PowerShellRunRequest request) + => _execute(request); + } + + private sealed class TemporaryPackageScope(string rootPath, string packagePath) : IDisposable + { + public string RootPath { get; } = rootPath; + public string PackagePath { get; } = packagePath; + + public void Dispose() + { + if (Directory.Exists(RootPath)) + { + Directory.Delete(RootPath, recursive: true); + } + } + } + + private sealed class TemporaryModuleScope(string rootPath, string moduleRoot) : IDisposable + { + public string RootPath { get; } = rootPath; + public string ModuleRoot { get; } = moduleRoot; + + public void Dispose() + { + if (Directory.Exists(RootPath)) + { + Directory.Delete(RootPath, recursive: true); + } + } + } +} diff --git a/PowerForge.Tests/ReleaseSigningHostSettingsResolverTests.cs b/PowerForge.Tests/ReleaseSigningHostSettingsResolverTests.cs new file mode 100644 index 00000000..13ef98ac --- /dev/null +++ b/PowerForge.Tests/ReleaseSigningHostSettingsResolverTests.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography.X509Certificates; + +namespace PowerForge.Tests; + +public sealed class ReleaseSigningHostSettingsResolverTests +{ + [Fact] + public void Resolve_UsesDefaultsAndReportsMissingThumbprint() + { + var resolver = new ReleaseSigningHostSettingsResolver( + getEnvironmentVariable: _ => null, + resolveModulePath: () => @"C:\Modules\PSPublishModule.psd1"); + + var settings = resolver.Resolve(); + + Assert.False(settings.IsConfigured); + Assert.Equal("CurrentUser", settings.StoreName); + Assert.Equal("http://timestamp.digicert.com", settings.TimeStampServer); + Assert.Equal(@"C:\Modules\PSPublishModule.psd1", settings.ModulePath); + Assert.Contains("RELEASE_OPS_STUDIO_SIGN_THUMBPRINT", settings.MissingConfigurationMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Resolve_UsesEnvironmentOverrides() + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase) { + ["RELEASE_OPS_STUDIO_SIGN_THUMBPRINT"] = " thumb ", + ["RELEASE_OPS_STUDIO_SIGN_STORE"] = " LocalMachine ", + ["RELEASE_OPS_STUDIO_SIGN_TIMESTAMP_URL"] = " https://timestamp.contoso.test ", + ["RELEASE_OPS_STUDIO_PSPUBLISHMODULE_PATH"] = @" C:\Temp\PSPublishModule.psd1 " + }; + + var resolver = new ReleaseSigningHostSettingsResolver( + getEnvironmentVariable: name => values.TryGetValue(name, out var value) ? value : null, + resolveModulePath: () => @"C:\Ignored\PSPublishModule.psd1"); + + var settings = resolver.Resolve(); + + Assert.True(settings.IsConfigured); + Assert.Equal("thumb", settings.Thumbprint); + Assert.Equal("LocalMachine", settings.StoreName); + Assert.Equal("https://timestamp.contoso.test", settings.TimeStampServer); + Assert.Equal(@"C:\Temp\PSPublishModule.psd1", settings.ModulePath); + } +} + +public sealed class CertificateFingerprintResolverTests +{ + [Fact] + public void ResolveSha256_NormalizesThumbprintsAndMapsStoreName() + { + StoreLocation? capturedStoreLocation = null; + string? capturedThumbprint = null; + var resolver = new CertificateFingerprintResolver((storeLocation, normalizedThumbprint) => { + capturedStoreLocation = storeLocation; + capturedThumbprint = normalizedThumbprint; + return "ABC123"; + }); + + var fingerprint = resolver.ResolveSha256("ab cd 12", "LocalMachine"); + + Assert.Equal("ABC123", fingerprint); + Assert.Equal(StoreLocation.LocalMachine, capturedStoreLocation); + Assert.Equal("ABCD12", capturedThumbprint); + } +} diff --git a/PowerForge.Tests/RunnerHousekeepingServiceTests.cs b/PowerForge.Tests/RunnerHousekeepingServiceTests.cs new file mode 100644 index 00000000..c732da7f --- /dev/null +++ b/PowerForge.Tests/RunnerHousekeepingServiceTests.cs @@ -0,0 +1,115 @@ +namespace PowerForge.Tests; + +public sealed class RunnerHousekeepingServiceTests +{ + [Fact] + public void Clean_DryRun_PlansDiagnosticsAndRunnerTempCleanup() + { + var root = CreateSandbox(); + try + { + var runnerRoot = Path.Combine(root, "runner"); + var workRoot = Path.Combine(runnerRoot, "_work"); + var runnerTemp = Path.Combine(workRoot, "_temp"); + var diagRoot = Path.Combine(runnerRoot, "_diag"); + Directory.CreateDirectory(runnerTemp); + Directory.CreateDirectory(diagRoot); + + var tempFile = Path.Combine(runnerTemp, "temp.txt"); + File.WriteAllText(tempFile, "temp"); + + var oldDiag = Path.Combine(diagRoot, "old.log"); + File.WriteAllText(oldDiag, "old"); + File.SetLastWriteTimeUtc(oldDiag, DateTime.UtcNow.AddDays(-20)); + + var service = new RunnerHousekeepingService(new NullLogger()); + var result = service.Clean(new RunnerHousekeepingSpec + { + RunnerTempPath = runnerTemp, + RunnerRootPath = runnerRoot, + WorkRootPath = workRoot, + DiagnosticsRootPath = diagRoot, + DryRun = true, + Aggressive = false, + ClearDotNetCaches = false, + PruneDocker = false + }); + + Assert.True(result.Success); + Assert.True(result.DryRun); + Assert.Equal(2, result.Steps.Length); + Assert.Contains(result.Steps, s => s.Id == "diag" && s.DryRun && s.EntriesAffected == 1); + Assert.Contains(result.Steps, s => s.Id == "runner-temp" && s.DryRun && s.EntriesAffected == 1); + Assert.True(File.Exists(oldDiag)); + Assert.True(File.Exists(tempFile)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Clean_Apply_AggressiveModeDeletesOldDirectories() + { + var root = CreateSandbox(); + try + { + var runnerRoot = Path.Combine(root, "runner"); + var workRoot = Path.Combine(runnerRoot, "_work"); + var runnerTemp = Path.Combine(workRoot, "_temp"); + var actionsRoot = Path.Combine(workRoot, "_actions"); + var toolCache = Path.Combine(root, "toolcache"); + + Directory.CreateDirectory(runnerTemp); + Directory.CreateDirectory(actionsRoot); + Directory.CreateDirectory(toolCache); + + var oldActionDir = Path.Combine(actionsRoot, "old-action"); + var oldToolDir = Path.Combine(toolCache, "old-tool"); + Directory.CreateDirectory(oldActionDir); + Directory.CreateDirectory(oldToolDir); + + File.WriteAllText(Path.Combine(oldActionDir, "a.txt"), "x"); + File.WriteAllText(Path.Combine(oldToolDir, "b.txt"), "x"); + Directory.SetLastWriteTimeUtc(oldActionDir, DateTime.UtcNow.AddDays(-10)); + Directory.SetLastWriteTimeUtc(oldToolDir, DateTime.UtcNow.AddDays(-40)); + + var service = new RunnerHousekeepingService(new NullLogger()); + var result = service.Clean(new RunnerHousekeepingSpec + { + RunnerTempPath = runnerTemp, + RunnerRootPath = runnerRoot, + WorkRootPath = workRoot, + ToolCachePath = toolCache, + DryRun = false, + Aggressive = true, + ClearDotNetCaches = false, + PruneDocker = false + }); + + Assert.True(result.Success); + Assert.True(result.AggressiveApplied); + Assert.DoesNotContain(result.Steps, s => !s.Success); + Assert.False(Directory.Exists(oldActionDir)); + Assert.False(Directory.Exists(oldToolDir)); + } + finally + { + TryDelete(root); + } + } + + private static string CreateSandbox() + { + var path = Path.Combine(Path.GetTempPath(), "PowerForge.RunnerHousekeepingTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } +} diff --git a/PowerForge.Tests/packages.lock.json b/PowerForge.Tests/packages.lock.json index 8c29dcf2..ad64b3a1 100644 --- a/PowerForge.Tests/packages.lock.json +++ b/PowerForge.Tests/packages.lock.json @@ -855,7 +855,7 @@ "YamlDotNet": "[15.1.2, )" } }, - "powerforge.web.cli": { + "PowerForge.Web.Build": { "type": "Project", "dependencies": { "PowerForge.Web": "[1.0.0, )" diff --git a/PowerForge.Web.Cli/PowerForge.Web.Cli.csproj b/PowerForge.Web.Cli/PowerForge.Web.Cli.csproj index aef1155b..b6ce151c 100644 --- a/PowerForge.Web.Cli/PowerForge.Web.Cli.csproj +++ b/PowerForge.Web.Cli/PowerForge.Web.Cli.csproj @@ -7,11 +7,19 @@ enable PowerForge.Web.Cli PowerForge.Web.Cli + PowerForge.Web.Build true powerforge-web PowerForge.Web command-line interface for building and serving static sites. 1.0.0 true + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + website;static-site;docs;automation;cli;tool + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge.Web/PowerForge.Web.csproj b/PowerForge.Web/PowerForge.Web.csproj index a08ea426..ad42cc2a 100644 --- a/PowerForge.Web/PowerForge.Web.csproj +++ b/PowerForge.Web/PowerForge.Web.csproj @@ -4,9 +4,19 @@ net8.0;net10.0 enable enable + 1.0.0 + PowerForge.Web PowerForge.Web + PowerForge.Web static-site engine and reusable website build components. true true + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + website;static-site;docs;automation;powerforge + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge/Abstractions/IPowerShellRunner.cs b/PowerForge/Abstractions/IPowerShellRunner.cs index 3d882594..29254d6e 100644 --- a/PowerForge/Abstractions/IPowerShellRunner.cs +++ b/PowerForge/Abstractions/IPowerShellRunner.cs @@ -5,24 +5,122 @@ namespace PowerForge; +/// +/// Supported PowerShell invocation modes. +/// +public enum PowerShellInvocationMode +{ + /// + /// Executes a script path via -File. + /// + File = 0, + + /// + /// Executes inline PowerShell text via -Command. + /// + Command = 1 +} + /// /// Request to execute a PowerShell script out-of-process. /// public sealed class PowerShellRunRequest { /// Path to the script to execute with -File. - public string ScriptPath { get; } + public string? ScriptPath { get; } + /// Inline PowerShell text to execute with -Command. + public string? CommandText { get; } /// Arguments passed to the script (after -File). public IReadOnlyList Arguments { get; } /// Maximum allowed execution time before killing the process. public TimeSpan Timeout { get; } /// When true, prefer pwsh; otherwise use Windows PowerShell first on Windows. public bool PreferPwsh { get; } + /// Optional working directory for the PowerShell process. + public string? WorkingDirectory { get; } + /// Optional environment variable overrides for the PowerShell process. + public IReadOnlyDictionary? EnvironmentVariables { get; } + /// Optional explicit executable name or path. + public string? ExecutableOverride { get; } + /// Gets the invocation mode for the request. + public PowerShellInvocationMode InvocationMode { get; } /// - /// Creates a new request. + /// Creates a new file-based request. /// - public PowerShellRunRequest(string scriptPath, IReadOnlyList arguments, TimeSpan timeout, bool preferPwsh = true) - { ScriptPath = scriptPath; Arguments = arguments; Timeout = timeout; PreferPwsh = preferPwsh; } + public PowerShellRunRequest( + string scriptPath, + IReadOnlyList arguments, + TimeSpan timeout, + bool preferPwsh = true, + string? workingDirectory = null, + IReadOnlyDictionary? environmentVariables = null, + string? executableOverride = null) + { + ScriptPath = scriptPath; + CommandText = null; + Arguments = arguments; + Timeout = timeout; + PreferPwsh = preferPwsh; + WorkingDirectory = workingDirectory; + EnvironmentVariables = environmentVariables; + ExecutableOverride = executableOverride; + InvocationMode = PowerShellInvocationMode.File; + } + + /// + /// Creates a new command-based request. + /// + /// Inline PowerShell text to execute with -Command. + /// Maximum allowed execution time before killing the process. + /// When true, prefer pwsh; otherwise use Windows PowerShell first on Windows. + /// Optional working directory for the PowerShell process. + /// Optional environment variable overrides. + /// Optional explicit executable name or path. + /// PowerShell command request. + public static PowerShellRunRequest ForCommand( + string commandText, + TimeSpan timeout, + bool preferPwsh = true, + string? workingDirectory = null, + IReadOnlyDictionary? environmentVariables = null, + string? executableOverride = null) + { + if (string.IsNullOrWhiteSpace(commandText)) + throw new ArgumentException("Command text is required.", nameof(commandText)); + + return new PowerShellRunRequest( + scriptPath: null, + commandText: commandText, + arguments: Array.Empty(), + timeout: timeout, + preferPwsh: preferPwsh, + workingDirectory: workingDirectory, + environmentVariables: environmentVariables, + executableOverride: executableOverride, + invocationMode: PowerShellInvocationMode.Command); + } + + private PowerShellRunRequest( + string? scriptPath, + string? commandText, + IReadOnlyList arguments, + TimeSpan timeout, + bool preferPwsh, + string? workingDirectory, + IReadOnlyDictionary? environmentVariables, + string? executableOverride, + PowerShellInvocationMode invocationMode) + { + ScriptPath = scriptPath; + CommandText = commandText; + Arguments = arguments; + Timeout = timeout; + PreferPwsh = preferPwsh; + WorkingDirectory = workingDirectory; + EnvironmentVariables = environmentVariables; + ExecutableOverride = executableOverride; + InvocationMode = invocationMode; + } } /// /// Result of a PowerShell process execution. @@ -58,126 +156,52 @@ public interface IPowerShellRunner /// public sealed class PowerShellRunner : IPowerShellRunner { + private readonly IProcessRunner _processRunner; + + /// + /// Initializes a new instance of the class. + /// + /// Optional external process runner implementation. + public PowerShellRunner(IProcessRunner? processRunner = null) + { + _processRunner = processRunner ?? new ProcessRunner(); + } + /// public PowerShellRunResult Run(PowerShellRunRequest request) { - var exe = ResolveExecutable(request.PreferPwsh); + var exe = ResolveExecutable(request.PreferPwsh, request.ExecutableOverride); if (exe is null) { return new PowerShellRunResult(127, string.Empty, "No PowerShell executable found (pwsh or powershell.exe).", string.Empty); } - var psi = new ProcessStartInfo(); - psi.FileName = exe; - psi.RedirectStandardOutput = true; - psi.RedirectStandardError = true; - psi.UseShellExecute = false; - psi.CreateNoWindow = true; - ProcessStartInfoEncoding.TryApplyUtf8(psi); - -#if NET472 - // Build classic argument string for net472 - var sb = new System.Text.StringBuilder(); - void AddArg(string s) - { - if (sb.Length > 0) sb.Append(' '); - if (string.IsNullOrEmpty(s)) - { - sb.Append("\"\""); - return; - } - if (s.IndexOf(' ') >= 0 || s.IndexOf('"') >= 0) - { - sb.Append('"').Append(s.Replace("\"", "\\\"")).Append('"'); - } - else sb.Append(s); - } - AddArg("-NoProfile"); - AddArg("-NonInteractive"); - AddArg("-ExecutionPolicy"); - AddArg("Bypass"); - AddArg("-File"); - AddArg(request.ScriptPath); - foreach (var arg in request.Arguments) - { - AddArg(arg); - } - psi.Arguments = sb.ToString(); -#else - psi.ArgumentList.Add("-NoProfile"); - psi.ArgumentList.Add("-NonInteractive"); - psi.ArgumentList.Add("-ExecutionPolicy"); - psi.ArgumentList.Add("Bypass"); - psi.ArgumentList.Add("-File"); - psi.ArgumentList.Add(request.ScriptPath); - foreach (var arg in request.Arguments) - { - psi.ArgumentList.Add(arg); - } -#endif - - using var p = new Process { StartInfo = psi }; - - try { p.Start(); } - catch (Exception ex) - { - return new PowerShellRunResult(127, string.Empty, ex.Message, exe); - } - - // Read output asynchronously to avoid deadlocks when the child process writes a lot of data. - // (Waiting for exit before draining stdout/stderr can cause the child process to block on full buffers.) - var stdoutTask = p.StandardOutput.ReadToEndAsync(); - var stderrTask = p.StandardError.ReadToEndAsync(); + var arguments = BuildArguments(request); + var processResult = _processRunner.RunAsync( + new ProcessRunRequest( + exe, + request.WorkingDirectory ?? Environment.CurrentDirectory, + arguments, + request.Timeout, + request.EnvironmentVariables)).GetAwaiter().GetResult(); - var timeoutMs = (int)Math.Max(1, Math.Min(int.MaxValue, request.Timeout.TotalMilliseconds)); - if (!p.WaitForExit(timeoutMs)) - { - try - { -#if NET472 - p.Kill(); -#else - p.Kill(entireProcessTree: true); -#endif - } - catch { /* ignore */ } - - try { p.WaitForExit(5000); } catch { /* ignore */ } - - var stdout = TryGetCompletedTaskResult(stdoutTask); - var stderr = TryGetCompletedTaskResult(stderrTask); - if (string.IsNullOrWhiteSpace(stderr)) stderr = "Timeout"; - - return new PowerShellRunResult(124, stdout, stderr, exe); - } - - // Ensure the async readers have completed after process exit. - try { Task.WaitAll(new Task[] { stdoutTask, stderrTask }, 5000); } catch { /* ignore */ } - - var finalStdout = TryGetCompletedTaskResult(stdoutTask); - var finalStderr = TryGetCompletedTaskResult(stderrTask); - return new PowerShellRunResult(p.ExitCode, finalStdout, finalStderr, exe); - } - - private static string TryGetCompletedTaskResult(Task task) - { - if (task is null) return string.Empty; - - try - { - if (task.Status == TaskStatus.RanToCompletion) return task.Result ?? string.Empty; - if (task.Wait(10_000)) return task.Result ?? string.Empty; - } - catch { /* ignore */ } - - return string.Empty; + return new PowerShellRunResult(processResult.ExitCode, processResult.StdOut, processResult.StdErr, exe); } /// /// Resolves pwsh or Windows PowerShell on PATH depending on . /// - private static string? ResolveExecutable(bool preferPwsh) + private static string? ResolveExecutable(bool preferPwsh, string? executableOverride) { + if (!string.IsNullOrWhiteSpace(executableOverride)) + { + var overridePath = ResolveOnPath(executableOverride); + if (overridePath is not null) + return overridePath; + if (File.Exists(executableOverride)) + return executableOverride; + } + var isWindows = Path.DirectorySeparatorChar == '\\'; string[] candidates = preferPwsh ? (isWindows ? new[] { "pwsh.exe", "powershell.exe" } : new[] { "pwsh" }) @@ -191,6 +215,30 @@ private static string TryGetCompletedTaskResult(Task task) return null; } + private static IReadOnlyList BuildArguments(PowerShellRunRequest request) + { + var arguments = new List { + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass" + }; + + if (request.InvocationMode == PowerShellInvocationMode.Command) + { + arguments.Add("-Command"); + arguments.Add(request.CommandText ?? string.Empty); + return arguments; + } + + arguments.Add("-File"); + arguments.Add(request.ScriptPath ?? string.Empty); + foreach (var arg in request.Arguments) + arguments.Add(arg); + + return arguments; + } + /// /// Resolves using the PATH environment variable. /// diff --git a/PowerForge/Abstractions/IProcessRunner.cs b/PowerForge/Abstractions/IProcessRunner.cs new file mode 100644 index 00000000..9fd06121 --- /dev/null +++ b/PowerForge/Abstractions/IProcessRunner.cs @@ -0,0 +1,308 @@ +using System.Diagnostics; + +namespace PowerForge; + +/// +/// Request to execute an external process with structured arguments. +/// +public sealed class ProcessRunRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// Executable name or path. + /// Working directory for the process. + /// Structured arguments passed to the process. + /// Maximum runtime before the process is terminated. + /// Optional environment variable overrides. + public ProcessRunRequest( + string fileName, + string workingDirectory, + IReadOnlyList arguments, + TimeSpan timeout, + IReadOnlyDictionary? environmentVariables = null) + { + FileName = fileName; + WorkingDirectory = workingDirectory; + Arguments = arguments; + Timeout = timeout; + EnvironmentVariables = environmentVariables; + } + + /// + /// Gets the executable name or path. + /// + public string FileName { get; } + + /// + /// Gets the working directory for the process. + /// + public string WorkingDirectory { get; } + + /// + /// Gets the structured arguments passed to the process. + /// + public IReadOnlyList Arguments { get; } + + /// + /// Gets the maximum runtime before the process is terminated. + /// + public TimeSpan Timeout { get; } + + /// + /// Gets optional environment variable overrides. + /// + public IReadOnlyDictionary? EnvironmentVariables { get; } +} + +/// +/// Result of executing an external process. +/// +public sealed class ProcessRunResult +{ + /// + /// Initializes a new instance of the class. + /// + /// Process exit code. + /// Captured standard output. + /// Captured standard error. + /// Executable name or path used to launch the process. + /// Observed process duration. + /// Indicates whether the process timed out. + public ProcessRunResult( + int exitCode, + string stdOut, + string stdErr, + string executable, + TimeSpan duration, + bool timedOut) + { + ExitCode = exitCode; + StdOut = stdOut; + StdErr = stdErr; + Executable = executable; + Duration = duration; + TimedOut = timedOut; + } + + /// + /// Gets the process exit code. + /// + public int ExitCode { get; } + + /// + /// Gets captured standard output. + /// + public string StdOut { get; } + + /// + /// Gets captured standard error. + /// + public string StdErr { get; } + + /// + /// Gets the executable name or path used to launch the process. + /// + public string Executable { get; } + + /// + /// Gets the observed process duration. + /// + public TimeSpan Duration { get; } + + /// + /// Gets a value indicating whether the process timed out. + /// + public bool TimedOut { get; } + + /// + /// Gets a value indicating whether the process completed successfully. + /// + public bool Succeeded => ExitCode == 0 && !TimedOut; +} + +/// +/// Executes external processes with structured request/response contracts. +/// +public interface IProcessRunner +{ + /// + /// Runs the provided and returns the result. + /// + /// Process execution request. + /// Cancellation token. + /// Structured process execution result. + Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default); +} + +/// +/// Default implementation of . +/// +public sealed class ProcessRunner : IProcessRunner +{ + /// + public async Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.FileName)) + throw new ArgumentException("Executable name is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.WorkingDirectory)) + throw new ArgumentException("Working directory is required.", nameof(request)); + + using var process = new Process { + StartInfo = BuildStartInfo(request) + }; + + var stopwatch = Stopwatch.StartNew(); + try + { + process.Start(); + } + catch (Exception ex) + { + stopwatch.Stop(); + return new ProcessRunResult(127, string.Empty, ex.Message, request.FileName, stopwatch.Elapsed, timedOut: false); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + var timedOut = false; + + try + { + await WaitForExitAsync(process, request.Timeout, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + timedOut = !cancellationToken.IsCancellationRequested; + TryKill(process); + } + + try + { + if (!process.HasExited) + process.WaitForExit(5000); + } + catch + { + // Best-effort wait only. + } + + var stdout = await DrainAsync(stdoutTask).ConfigureAwait(false); + var stderr = await DrainAsync(stderrTask).ConfigureAwait(false); + stopwatch.Stop(); + + if (timedOut && string.IsNullOrWhiteSpace(stderr)) + stderr = "Timeout"; + + var exitCode = timedOut ? 124 : SafeGetExitCode(process); + return new ProcessRunResult(exitCode, stdout, stderr, process.StartInfo.FileName ?? request.FileName, stopwatch.Elapsed, timedOut); + } + + private static ProcessStartInfo BuildStartInfo(ProcessRunRequest request) + { + var startInfo = new ProcessStartInfo { + FileName = request.FileName, + WorkingDirectory = request.WorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + ProcessStartInfoEncoding.TryApplyUtf8(startInfo); + + if (request.EnvironmentVariables is not null) + { + foreach (var variable in request.EnvironmentVariables) + { + if (variable.Value is null) + { + startInfo.EnvironmentVariables.Remove(variable.Key); + continue; + } + + startInfo.EnvironmentVariables[variable.Key] = variable.Value; + } + } + +#if NET472 + startInfo.Arguments = string.Join(" ", request.Arguments.Select(QuoteArgument)); +#else + foreach (var argument in request.Arguments) + startInfo.ArgumentList.Add(argument); +#endif + + return startInfo; + } + + private static async Task WaitForExitAsync(Process process, TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (timeout > TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan) + timeoutCts.CancelAfter(timeout); + + while (!process.HasExited) + { + timeoutCts.Token.ThrowIfCancellationRequested(); + await Task.Delay(50, CancellationToken.None).ConfigureAwait(false); + } + } + + private static async Task DrainAsync(Task readTask) + { + try + { + return await readTask.ConfigureAwait(false); + } + catch + { + return string.Empty; + } + } + + private static int SafeGetExitCode(Process process) + { + try + { + return process.ExitCode; + } + catch + { + return 1; + } + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { +#if NET472 + process.Kill(); +#else + process.Kill(entireProcessTree: true); +#endif + } + } + catch + { + // Best-effort cleanup only. + } + } + +#if NET472 + private static string QuoteArgument(string argument) + { + if (string.IsNullOrEmpty(argument)) + return "\"\""; + + if (argument.IndexOfAny(new[] { ' ', '"' }) >= 0) + return "\"" + argument.Replace("\"", "\\\"") + "\""; + + return argument; + } +#endif +} diff --git a/PowerForge/InternalsVisibleTo.cs b/PowerForge/InternalsVisibleTo.cs index 5559c0fb..5612601b 100644 --- a/PowerForge/InternalsVisibleTo.cs +++ b/PowerForge/InternalsVisibleTo.cs @@ -1,5 +1,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("PowerForge.Tests")] +[assembly: InternalsVisibleTo("PowerForge.Cli")] [assembly: InternalsVisibleTo("PowerForge.PowerShell")] +[assembly: InternalsVisibleTo("PowerForgeStudio.Tests")] [assembly: InternalsVisibleTo("PSPublishModule")] diff --git a/PowerForge/Models/AuthenticodeSigningHostRequest.cs b/PowerForge/Models/AuthenticodeSigningHostRequest.cs new file mode 100644 index 00000000..564d57c0 --- /dev/null +++ b/PowerForge/Models/AuthenticodeSigningHostRequest.cs @@ -0,0 +1,37 @@ +namespace PowerForge; + +/// +/// Host-facing request for invoking Authenticode signing through PSPublishModule. +/// +public sealed class AuthenticodeSigningHostRequest +{ + /// + /// Working directory and target path passed to the signing command. + /// + public string SigningPath { get; set; } = string.Empty; + + /// + /// File include patterns passed to Register-Certificate. + /// + public IReadOnlyList IncludePatterns { get; set; } = Array.Empty(); + + /// + /// Path to the PSPublishModule manifest that should be imported. + /// + public string ModulePath { get; set; } = string.Empty; + + /// + /// Certificate thumbprint. + /// + public string Thumbprint { get; set; } = string.Empty; + + /// + /// Certificate store name. + /// + public string StoreName { get; set; } = string.Empty; + + /// + /// Timestamp server URI. + /// + public string TimeStampServer { get; set; } = string.Empty; +} diff --git a/PowerForge/Models/AuthenticodeSigningHostResult.cs b/PowerForge/Models/AuthenticodeSigningHostResult.cs new file mode 100644 index 00000000..1e23876b --- /dev/null +++ b/PowerForge/Models/AuthenticodeSigningHostResult.cs @@ -0,0 +1,37 @@ +namespace PowerForge; + +/// +/// Result returned by the shared Authenticode signing host service. +/// +public sealed class AuthenticodeSigningHostResult +{ + /// + /// Exit code returned by the PowerShell process. + /// + public int ExitCode { get; set; } + + /// + /// Execution duration. + /// + public TimeSpan Duration { get; set; } + + /// + /// Captured standard output. + /// + public string StandardOutput { get; set; } = string.Empty; + + /// + /// Captured standard error. + /// + public string StandardError { get; set; } = string.Empty; + + /// + /// PowerShell executable used for the run. + /// + public string Executable { get; set; } = string.Empty; + + /// + /// True when is zero. + /// + public bool Succeeded => ExitCode == 0; +} diff --git a/PowerForge/Models/DotNet/DotNetNuGetPushRequest.cs b/PowerForge/Models/DotNet/DotNetNuGetPushRequest.cs new file mode 100644 index 00000000..f672eca6 --- /dev/null +++ b/PowerForge/Models/DotNet/DotNetNuGetPushRequest.cs @@ -0,0 +1,62 @@ +namespace PowerForge; + +/// +/// Request to execute dotnet nuget push. +/// +public sealed class DotNetNuGetPushRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// Package path to push. + /// API key passed to the feed. + /// Feed source URL or name. + /// When true, passes --skip-duplicate. + /// Optional working directory override. + /// Optional timeout override. + public DotNetNuGetPushRequest( + string packagePath, + string apiKey, + string source, + bool skipDuplicate = true, + string? workingDirectory = null, + TimeSpan? timeout = null) + { + PackagePath = packagePath; + ApiKey = apiKey; + Source = source; + SkipDuplicate = skipDuplicate; + WorkingDirectory = workingDirectory; + Timeout = timeout; + } + + /// + /// Gets the package path to push. + /// + public string PackagePath { get; } + + /// + /// Gets the API key passed to the feed. + /// + public string ApiKey { get; } + + /// + /// Gets the feed source URL or name. + /// + public string Source { get; } + + /// + /// Gets a value indicating whether --skip-duplicate should be passed. + /// + public bool SkipDuplicate { get; } + + /// + /// Gets the optional working directory override. + /// + public string? WorkingDirectory { get; } + + /// + /// Gets the optional timeout override. + /// + public TimeSpan? Timeout { get; } +} diff --git a/PowerForge/Models/DotNet/DotNetNuGetPushResult.cs b/PowerForge/Models/DotNet/DotNetNuGetPushResult.cs new file mode 100644 index 00000000..86a9bb21 --- /dev/null +++ b/PowerForge/Models/DotNet/DotNetNuGetPushResult.cs @@ -0,0 +1,68 @@ +namespace PowerForge; + +/// +/// Result of executing dotnet nuget push. +/// +public sealed class DotNetNuGetPushResult +{ + /// + /// Initializes a new instance of the class. + /// + public DotNetNuGetPushResult( + int exitCode, + string stdOut, + string stdErr, + string executable, + TimeSpan duration, + bool timedOut, + string? errorMessage) + { + ExitCode = exitCode; + StdOut = stdOut; + StdErr = stdErr; + Executable = executable; + Duration = duration; + TimedOut = timedOut; + ErrorMessage = errorMessage; + } + + /// + /// Gets the process exit code. + /// + public int ExitCode { get; } + + /// + /// Gets the captured standard output. + /// + public string StdOut { get; } + + /// + /// Gets the captured standard error. + /// + public string StdErr { get; } + + /// + /// Gets the executable used to run the command. + /// + public string Executable { get; } + + /// + /// Gets the observed process duration. + /// + public TimeSpan Duration { get; } + + /// + /// Gets a value indicating whether the command timed out. + /// + public bool TimedOut { get; } + + /// + /// Gets the first meaningful error message, when available. + /// + public string? ErrorMessage { get; } + + /// + /// Gets a value indicating whether the push completed successfully. + /// + public bool Succeeded => ExitCode == 0 && !TimedOut; +} diff --git a/PowerForge/Models/DotNet/DotNetNuGetSignRequest.cs b/PowerForge/Models/DotNet/DotNetNuGetSignRequest.cs new file mode 100644 index 00000000..cb6e8427 --- /dev/null +++ b/PowerForge/Models/DotNet/DotNetNuGetSignRequest.cs @@ -0,0 +1,78 @@ +namespace PowerForge; + +/// +/// Request to execute dotnet nuget sign. +/// +public sealed class DotNetNuGetSignRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// Package path to sign. + /// SHA256 certificate fingerprint. + /// Certificate store location. + /// Timestamp server URL. + /// Certificate store name. Defaults to My. + /// When true, passes --overwrite. + /// Optional working directory override. + /// Optional timeout override. + public DotNetNuGetSignRequest( + string packagePath, + string certificateFingerprint, + string certificateStoreLocation, + string timeStampServer, + string certificateStoreName = "My", + bool overwrite = true, + string? workingDirectory = null, + TimeSpan? timeout = null) + { + PackagePath = packagePath; + CertificateFingerprint = certificateFingerprint; + CertificateStoreLocation = certificateStoreLocation; + CertificateStoreName = certificateStoreName; + TimeStampServer = timeStampServer; + Overwrite = overwrite; + WorkingDirectory = workingDirectory; + Timeout = timeout; + } + + /// + /// Gets the package path to sign. + /// + public string PackagePath { get; } + + /// + /// Gets the SHA256 certificate fingerprint. + /// + public string CertificateFingerprint { get; } + + /// + /// Gets the certificate store location. + /// + public string CertificateStoreLocation { get; } + + /// + /// Gets the certificate store name. + /// + public string CertificateStoreName { get; } + + /// + /// Gets the timestamp server URL. + /// + public string TimeStampServer { get; } + + /// + /// Gets a value indicating whether --overwrite should be passed. + /// + public bool Overwrite { get; } + + /// + /// Gets the optional working directory override. + /// + public string? WorkingDirectory { get; } + + /// + /// Gets the optional timeout override. + /// + public TimeSpan? Timeout { get; } +} diff --git a/PowerForge/Models/DotNet/DotNetNuGetSignResult.cs b/PowerForge/Models/DotNet/DotNetNuGetSignResult.cs new file mode 100644 index 00000000..02258355 --- /dev/null +++ b/PowerForge/Models/DotNet/DotNetNuGetSignResult.cs @@ -0,0 +1,68 @@ +namespace PowerForge; + +/// +/// Result of executing dotnet nuget sign. +/// +public sealed class DotNetNuGetSignResult +{ + /// + /// Initializes a new instance of the class. + /// + public DotNetNuGetSignResult( + int exitCode, + string stdOut, + string stdErr, + string executable, + TimeSpan duration, + bool timedOut, + string? errorMessage) + { + ExitCode = exitCode; + StdOut = stdOut; + StdErr = stdErr; + Executable = executable; + Duration = duration; + TimedOut = timedOut; + ErrorMessage = errorMessage; + } + + /// + /// Gets the process exit code. + /// + public int ExitCode { get; } + + /// + /// Gets the captured standard output. + /// + public string StdOut { get; } + + /// + /// Gets the captured standard error. + /// + public string StdErr { get; } + + /// + /// Gets the executable used to run the command. + /// + public string Executable { get; } + + /// + /// Gets the observed process duration. + /// + public TimeSpan Duration { get; } + + /// + /// Gets a value indicating whether the command timed out. + /// + public bool TimedOut { get; } + + /// + /// Gets the first meaningful error message, when available. + /// + public string? ErrorMessage { get; } + + /// + /// Gets a value indicating whether the sign completed successfully. + /// + public bool Succeeded => ExitCode == 0 && !TimedOut; +} diff --git a/PowerForge/Models/DotNetRepositoryReleaseResult.cs b/PowerForge/Models/DotNetRepositoryReleaseResult.cs index 7a0dbd00..b6302ed9 100644 --- a/PowerForge/Models/DotNetRepositoryReleaseResult.cs +++ b/PowerForge/Models/DotNetRepositoryReleaseResult.cs @@ -43,6 +43,9 @@ public sealed class DotNetRepositoryProjectResult /// Resolved csproj path. public string CsprojPath { get; set; } = string.Empty; + /// Resolved NuGet package identifier. + public string PackageId { get; set; } = string.Empty; + /// Whether the project is considered packable. public bool IsPackable { get; set; } diff --git a/PowerForge/Models/Git/GitCommandKind.cs b/PowerForge/Models/Git/GitCommandKind.cs new file mode 100644 index 00000000..9761c547 --- /dev/null +++ b/PowerForge/Models/Git/GitCommandKind.cs @@ -0,0 +1,47 @@ +namespace PowerForge; + +/// +/// Supported typed git commands. +/// +public enum GitCommandKind +{ + /// + /// Executes git status --porcelain=2 --branch. + /// + StatusPorcelainBranch = 0, + + /// + /// Executes git status --short --branch. + /// + StatusShortBranch = 1, + + /// + /// Executes git status --short. + /// + StatusShort = 2, + + /// + /// Executes git rev-parse --show-toplevel. + /// + ShowTopLevel = 3, + + /// + /// Executes git pull --rebase. + /// + PullRebase = 4, + + /// + /// Executes git switch -c <branch>. + /// + CreateBranch = 5, + + /// + /// Executes git push --set-upstream <remote> <branch>. + /// + SetUpstream = 6, + + /// + /// Executes git remote get-url <remote>. + /// + GetRemoteUrl = 7 +} diff --git a/PowerForge/Models/Git/GitCommandRequest.cs b/PowerForge/Models/Git/GitCommandRequest.cs new file mode 100644 index 00000000..d94a08b7 --- /dev/null +++ b/PowerForge/Models/Git/GitCommandRequest.cs @@ -0,0 +1,54 @@ +namespace PowerForge; + +/// +/// Typed request for a git command execution. +/// +public sealed class GitCommandRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// Repository working directory. + /// Typed git command to execute. + /// Optional branch name used by branch-aware commands. + /// Optional remote name used by remote-aware commands. + /// Optional timeout override. + public GitCommandRequest( + string workingDirectory, + GitCommandKind commandKind, + string? branchName = null, + string? remoteName = null, + TimeSpan? timeout = null) + { + WorkingDirectory = workingDirectory; + CommandKind = commandKind; + BranchName = branchName; + RemoteName = remoteName; + Timeout = timeout; + } + + /// + /// Gets the repository working directory. + /// + public string WorkingDirectory { get; } + + /// + /// Gets the typed git command to execute. + /// + public GitCommandKind CommandKind { get; } + + /// + /// Gets the optional branch name. + /// + public string? BranchName { get; } + + /// + /// Gets the optional remote name. + /// + public string? RemoteName { get; } + + /// + /// Gets the optional timeout override. + /// + public TimeSpan? Timeout { get; } +} diff --git a/PowerForge/Models/Git/GitCommandResult.cs b/PowerForge/Models/Git/GitCommandResult.cs new file mode 100644 index 00000000..998a1b0d --- /dev/null +++ b/PowerForge/Models/Git/GitCommandResult.cs @@ -0,0 +1,91 @@ +namespace PowerForge; + +/// +/// Result of executing a typed git command. +/// +public sealed class GitCommandResult +{ + /// + /// Initializes a new instance of the class. + /// + /// Typed git command that was executed. + /// Repository working directory. + /// Display-friendly git command text. + /// Process exit code. + /// Captured standard output. + /// Captured standard error. + /// Executable used to launch git. + /// Observed execution duration. + /// Indicates whether the command timed out. + public GitCommandResult( + GitCommandKind commandKind, + string workingDirectory, + string displayCommand, + int exitCode, + string stdOut, + string stdErr, + string executable, + TimeSpan duration, + bool timedOut) + { + CommandKind = commandKind; + WorkingDirectory = workingDirectory; + DisplayCommand = displayCommand; + ExitCode = exitCode; + StdOut = stdOut; + StdErr = stdErr; + Executable = executable; + Duration = duration; + TimedOut = timedOut; + } + + /// + /// Gets the typed git command that was executed. + /// + public GitCommandKind CommandKind { get; } + + /// + /// Gets the repository working directory. + /// + public string WorkingDirectory { get; } + + /// + /// Gets the display-friendly git command text. + /// + public string DisplayCommand { get; } + + /// + /// Gets the process exit code. + /// + public int ExitCode { get; } + + /// + /// Gets captured standard output. + /// + public string StdOut { get; } + + /// + /// Gets captured standard error. + /// + public string StdErr { get; } + + /// + /// Gets the executable used to launch git. + /// + public string Executable { get; } + + /// + /// Gets the observed execution duration. + /// + public TimeSpan Duration { get; } + + /// + /// Gets a value indicating whether the command timed out. + /// + public bool TimedOut { get; } + + /// + /// Gets a value indicating whether the command completed successfully. + /// + public bool Succeeded => ExitCode == 0 && !TimedOut; +} diff --git a/PowerForge/Models/Git/GitStatusSnapshot.cs b/PowerForge/Models/Git/GitStatusSnapshot.cs new file mode 100644 index 00000000..0eec4ae6 --- /dev/null +++ b/PowerForge/Models/Git/GitStatusSnapshot.cs @@ -0,0 +1,78 @@ +namespace PowerForge; + +/// +/// Parsed repository status snapshot derived from git status --porcelain=2 --branch. +/// +public sealed class GitStatusSnapshot +{ + /// + /// Initializes a new instance of the class. + /// + /// Indicates whether git metadata was available. + /// Current branch name. + /// Current upstream branch. + /// Ahead count versus upstream. + /// Behind count versus upstream. + /// Tracked change count. + /// Untracked change count. + /// Underlying typed git command result. + public GitStatusSnapshot( + bool isGitRepository, + string? branchName, + string? upstreamBranch, + int aheadCount, + int behindCount, + int trackedChangeCount, + int untrackedChangeCount, + GitCommandResult commandResult) + { + IsGitRepository = isGitRepository; + BranchName = branchName; + UpstreamBranch = upstreamBranch; + AheadCount = aheadCount; + BehindCount = behindCount; + TrackedChangeCount = trackedChangeCount; + UntrackedChangeCount = untrackedChangeCount; + CommandResult = commandResult; + } + + /// + /// Gets a value indicating whether git metadata was available. + /// + public bool IsGitRepository { get; } + + /// + /// Gets the current branch name. + /// + public string? BranchName { get; } + + /// + /// Gets the current upstream branch. + /// + public string? UpstreamBranch { get; } + + /// + /// Gets the ahead count versus upstream. + /// + public int AheadCount { get; } + + /// + /// Gets the behind count versus upstream. + /// + public int BehindCount { get; } + + /// + /// Gets the tracked change count. + /// + public int TrackedChangeCount { get; } + + /// + /// Gets the untracked change count. + /// + public int UntrackedChangeCount { get; } + + /// + /// Gets the underlying typed git command result. + /// + public GitCommandResult CommandResult { get; } +} diff --git a/PowerForge/Models/GitHubActionsCacheCleanup.cs b/PowerForge/Models/GitHubActionsCacheCleanup.cs new file mode 100644 index 00000000..2ec7ed12 --- /dev/null +++ b/PowerForge/Models/GitHubActionsCacheCleanup.cs @@ -0,0 +1,259 @@ +using System; + +namespace PowerForge; + +/// +/// Specification for pruning GitHub Actions dependency caches in a repository. +/// +public sealed class GitHubActionsCacheCleanupSpec +{ + /// + /// GitHub API base URL (for example https://api.github.com/). + /// + public string? ApiBaseUrl { get; set; } + + /// + /// Repository in owner/repo format. + /// + public string Repository { get; set; } = string.Empty; + + /// + /// GitHub token used to access the Actions caches API. + /// + public string Token { get; set; } = string.Empty; + + /// + /// Cache key patterns to include (wildcards and re: regex supported). + /// When empty, all cache keys are eligible. + /// + public string[] IncludeKeys { get; set; } = Array.Empty(); + + /// + /// Cache key patterns to exclude (wildcards and re: regex supported). + /// + public string[] ExcludeKeys { get; set; } = Array.Empty(); + + /// + /// Number of newest caches to keep per cache key. + /// + public int KeepLatestPerKey { get; set; } = 1; + + /// + /// Minimum cache age in days before deletion is allowed. + /// Set to null (or less than 1) to disable age filtering. + /// + public int? MaxAgeDays { get; set; } = 14; + + /// + /// Maximum number of caches to delete in a single run. + /// + public int MaxDelete { get; set; } = 200; + + /// + /// Number of caches returned per GitHub API page (1-100). + /// + public int PageSize { get; set; } = 100; + + /// + /// When true, only plans deletions and does not call delete endpoints. + /// + public bool DryRun { get; set; } = true; + + /// + /// When true, the run is marked failed when any delete request fails. + /// + public bool FailOnDeleteError { get; set; } +} + +/// +/// Single cache record included in cleanup output. +/// +public sealed class GitHubActionsCacheCleanupItem +{ + /// + /// GitHub cache identifier. + /// + public long Id { get; set; } + + /// + /// Cache key. + /// + public string Key { get; set; } = string.Empty; + + /// + /// Git reference associated with the cache. + /// + public string? Ref { get; set; } + + /// + /// Cache version fingerprint reported by GitHub. + /// + public string? Version { get; set; } + + /// + /// Size in bytes. + /// + public long SizeInBytes { get; set; } + + /// + /// Creation timestamp (UTC) when available. + /// + public DateTimeOffset? CreatedAt { get; set; } + + /// + /// Last access timestamp (UTC) when available. + /// + public DateTimeOffset? LastAccessedAt { get; set; } + + /// + /// Reason this item was selected (or failed) in cleanup output. + /// + public string? Reason { get; set; } + + /// + /// HTTP status code from delete operation (when available). + /// + public int? DeleteStatusCode { get; set; } + + /// + /// Error message from delete operation (when available). + /// + public string? DeleteError { get; set; } +} + +/// +/// Repository cache usage snapshot returned by GitHub. +/// +public sealed class GitHubActionsCacheUsage +{ + /// + /// Total cache count reported by GitHub. + /// + public int ActiveCachesCount { get; set; } + + /// + /// Total cache size in bytes reported by GitHub. + /// + public long ActiveCachesSizeInBytes { get; set; } +} + +/// +/// Result summary for a GitHub Actions cache cleanup run. +/// +public sealed class GitHubActionsCacheCleanupResult +{ + /// + /// Repository in owner/repo format. + /// + public string Repository { get; set; } = string.Empty; + + /// + /// Effective include key patterns used by the run. + /// + public string[] IncludeKeys { get; set; } = Array.Empty(); + + /// + /// Effective exclude key patterns used by the run. + /// + public string[] ExcludeKeys { get; set; } = Array.Empty(); + + /// + /// Number of newest caches kept per cache key. + /// + public int KeepLatestPerKey { get; set; } + + /// + /// Minimum age requirement (days) applied during selection. + /// + public int? MaxAgeDays { get; set; } + + /// + /// Maximum number of deletions allowed by this run. + /// + public int MaxDelete { get; set; } + + /// + /// Whether run executed in dry-run mode. + /// + public bool DryRun { get; set; } + + /// + /// Cache usage reported by GitHub before cleanup started. + /// + public GitHubActionsCacheUsage? UsageBefore { get; set; } + + /// + /// Cache usage reported by GitHub after cleanup finished. + /// + public GitHubActionsCacheUsage? UsageAfter { get; set; } + + /// + /// Total caches scanned from GitHub. + /// + public int ScannedCaches { get; set; } + + /// + /// Caches matching include/exclude filters. + /// + public int MatchedCaches { get; set; } + + /// + /// Number of caches skipped because they are in the newest keep window. + /// + public int KeptByRecentWindow { get; set; } + + /// + /// Number of caches skipped because they are newer than the age threshold. + /// + public int KeptByAgeThreshold { get; set; } + + /// + /// Number of caches planned for deletion. + /// + public int PlannedDeletes { get; set; } + + /// + /// Total size in bytes of planned deletions. + /// + public long PlannedDeleteBytes { get; set; } + + /// + /// Number of caches successfully deleted. + /// + public int DeletedCaches { get; set; } + + /// + /// Total size in bytes of successfully deleted caches. + /// + public long DeletedBytes { get; set; } + + /// + /// Number of caches that failed deletion. + /// + public int FailedDeletes { get; set; } + + /// + /// Whether the run completed successfully. + /// + public bool Success { get; set; } = true; + + /// + /// Optional warning or non-fatal message. + /// + public string? Message { get; set; } + + /// + /// Caches selected for deletion. + /// + public GitHubActionsCacheCleanupItem[] Planned { get; set; } = Array.Empty(); + + /// + /// Caches successfully deleted. + /// + public GitHubActionsCacheCleanupItem[] Deleted { get; set; } = Array.Empty(); + + /// + /// Caches that failed deletion. + /// + public GitHubActionsCacheCleanupItem[] Failed { get; set; } = Array.Empty(); +} diff --git a/PowerForge/Models/GitHubHousekeeping.cs b/PowerForge/Models/GitHubHousekeeping.cs new file mode 100644 index 00000000..15c9a5b0 --- /dev/null +++ b/PowerForge/Models/GitHubHousekeeping.cs @@ -0,0 +1,303 @@ +using System; + +namespace PowerForge; + +/// +/// Top-level configuration for GitHub housekeeping runs. +/// +public sealed class GitHubHousekeepingSpec +{ + /// + /// Optional GitHub API base URL (for example https://api.github.com/). + /// + public string? ApiBaseUrl { get; set; } + + /// + /// Repository in owner/repo format. + /// + public string? Repository { get; set; } + + /// + /// Optional GitHub token used for artifact/cache cleanup. + /// + public string? Token { get; set; } + + /// + /// Environment variable name used to resolve the GitHub token when is not provided. + /// + public string TokenEnvName { get; set; } = "GITHUB_TOKEN"; + + /// + /// When true, only plans deletions and does not execute them. + /// + public bool DryRun { get; set; } = true; + + /// + /// Artifact cleanup settings. + /// + public GitHubHousekeepingArtifactSpec Artifacts { get; set; } = new(); + + /// + /// Cache cleanup settings. + /// + public GitHubHousekeepingCacheSpec Caches { get; set; } = new(); + + /// + /// Runner cleanup settings. + /// + public GitHubHousekeepingRunnerSpec Runner { get; set; } = new(); +} + +/// +/// Artifact cleanup configuration for a housekeeping run. +/// +public sealed class GitHubHousekeepingArtifactSpec +{ + /// + /// Whether artifact cleanup is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Artifact name patterns to include. + /// + public string[] IncludeNames { get; set; } = Array.Empty(); + + /// + /// Artifact name patterns to exclude. + /// + public string[] ExcludeNames { get; set; } = Array.Empty(); + + /// + /// Number of newest artifacts kept per artifact name. + /// + public int KeepLatestPerName { get; set; } = 5; + + /// + /// Minimum age in days before deletion is allowed. + /// + public int? MaxAgeDays { get; set; } = 7; + + /// + /// Maximum number of artifacts to delete in one run. + /// + public int MaxDelete { get; set; } = 200; + + /// + /// Number of API records requested per page. + /// + public int PageSize { get; set; } = 100; + + /// + /// When true, the run fails if an artifact delete request fails. + /// + public bool FailOnDeleteError { get; set; } +} + +/// +/// Cache cleanup configuration for a housekeeping run. +/// +public sealed class GitHubHousekeepingCacheSpec +{ + /// + /// Whether cache cleanup is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Cache key patterns to include. + /// + public string[] IncludeKeys { get; set; } = Array.Empty(); + + /// + /// Cache key patterns to exclude. + /// + public string[] ExcludeKeys { get; set; } = Array.Empty(); + + /// + /// Number of newest caches kept per cache key. + /// + public int KeepLatestPerKey { get; set; } = 1; + + /// + /// Minimum age in days before deletion is allowed. + /// + public int? MaxAgeDays { get; set; } = 14; + + /// + /// Maximum number of caches to delete in one run. + /// + public int MaxDelete { get; set; } = 200; + + /// + /// Number of API records requested per page. + /// + public int PageSize { get; set; } = 100; + + /// + /// When true, the run fails if a cache delete request fails. + /// + public bool FailOnDeleteError { get; set; } +} + +/// +/// Runner cleanup configuration for a housekeeping run. +/// +public sealed class GitHubHousekeepingRunnerSpec +{ + /// + /// Whether runner cleanup is enabled. + /// + public bool Enabled { get; set; } + + /// + /// Optional explicit runner temp directory. + /// + public string? RunnerTempPath { get; set; } + + /// + /// Optional explicit work root. + /// + public string? WorkRootPath { get; set; } + + /// + /// Optional explicit runner root. + /// + public string? RunnerRootPath { get; set; } + + /// + /// Optional explicit diagnostics root. + /// + public string? DiagnosticsRootPath { get; set; } + + /// + /// Optional explicit tool cache path. + /// + public string? ToolCachePath { get; set; } + + /// + /// Minimum free disk required after cleanup, in GiB. + /// + public int? MinFreeGb { get; set; } = 20; + + /// + /// Threshold below which aggressive cleanup is enabled, in GiB. + /// + public int? AggressiveThresholdGb { get; set; } + + /// + /// Retention window for diagnostics files. + /// + public int DiagnosticsRetentionDays { get; set; } = 14; + + /// + /// Retention window for action working sets. + /// + public int ActionsRetentionDays { get; set; } = 7; + + /// + /// Retention window for tool cache directories. + /// + public int ToolCacheRetentionDays { get; set; } = 30; + + /// + /// Forces aggressive cleanup. + /// + public bool Aggressive { get; set; } + + /// + /// Enables diagnostics cleanup. + /// + public bool CleanDiagnostics { get; set; } = true; + + /// + /// Enables runner temp cleanup. + /// + public bool CleanRunnerTemp { get; set; } = true; + + /// + /// Enables action working set cleanup. + /// + public bool CleanActionsCache { get; set; } = true; + + /// + /// Enables runner tool cache cleanup. + /// + public bool CleanToolCache { get; set; } = true; + + /// + /// Enables dotnet nuget locals all --clear. + /// + public bool ClearDotNetCaches { get; set; } = true; + + /// + /// Enables Docker prune. + /// + public bool PruneDocker { get; set; } = true; + + /// + /// Includes Docker volumes during prune. + /// + public bool IncludeDockerVolumes { get; set; } = true; + + /// + /// Allows sudo on Unix for protected directories. + /// + public bool AllowSudo { get; set; } +} + +/// +/// Aggregate result for a GitHub housekeeping run. +/// +public sealed class GitHubHousekeepingResult +{ + /// + /// Repository used for remote GitHub cleanup. + /// + public string? Repository { get; set; } + + /// + /// Whether the run executed in dry-run mode. + /// + public bool DryRun { get; set; } + + /// + /// Whether the overall housekeeping run succeeded. + /// + public bool Success { get; set; } = true; + + /// + /// Optional warning or error message. + /// + public string? Message { get; set; } + + /// + /// Enabled sections in evaluation order. + /// + public string[] RequestedSections { get; set; } = Array.Empty(); + + /// + /// Sections that completed successfully. + /// + public string[] CompletedSections { get; set; } = Array.Empty(); + + /// + /// Sections that failed. + /// + public string[] FailedSections { get; set; } = Array.Empty(); + + /// + /// Artifact cleanup result when artifact cleanup is enabled. + /// + public GitHubArtifactCleanupResult? Artifacts { get; set; } + + /// + /// Cache cleanup result when cache cleanup is enabled. + /// + public GitHubActionsCacheCleanupResult? Caches { get; set; } + + /// + /// Runner cleanup result when runner cleanup is enabled. + /// + public RunnerHousekeepingResult? Runner { get; set; } +} diff --git a/PowerForge/Models/ModuleBuildHostBuildRequest.cs b/PowerForge/Models/ModuleBuildHostBuildRequest.cs new file mode 100644 index 00000000..b2a64658 --- /dev/null +++ b/PowerForge/Models/ModuleBuildHostBuildRequest.cs @@ -0,0 +1,22 @@ +namespace PowerForge; + +/// +/// Host-facing request for executing a module build script through shared orchestration. +/// +public sealed class ModuleBuildHostBuildRequest +{ + /// + /// Repository root used as the command working directory. + /// + public string RepositoryRoot { get; set; } = string.Empty; + + /// + /// Path to the repository's Build-Module.ps1 script. + /// + public string ScriptPath { get; set; } = string.Empty; + + /// + /// Path to the PSPublishModule manifest that should be imported. + /// + public string ModulePath { get; set; } = string.Empty; +} diff --git a/PowerForge/Models/ModuleBuildHostExecutionResult.cs b/PowerForge/Models/ModuleBuildHostExecutionResult.cs new file mode 100644 index 00000000..0d305a53 --- /dev/null +++ b/PowerForge/Models/ModuleBuildHostExecutionResult.cs @@ -0,0 +1,37 @@ +namespace PowerForge; + +/// +/// Result returned by the shared module build host service. +/// +public sealed class ModuleBuildHostExecutionResult +{ + /// + /// Exit code returned by the PowerShell process. + /// + public int ExitCode { get; set; } + + /// + /// Execution duration. + /// + public TimeSpan Duration { get; set; } + + /// + /// Captured standard output. + /// + public string StandardOutput { get; set; } = string.Empty; + + /// + /// Captured standard error. + /// + public string StandardError { get; set; } = string.Empty; + + /// + /// PowerShell executable used for the run. + /// + public string Executable { get; set; } = string.Empty; + + /// + /// True when is zero. + /// + public bool Succeeded => ExitCode == 0; +} diff --git a/PowerForge/Models/ModuleBuildHostExportRequest.cs b/PowerForge/Models/ModuleBuildHostExportRequest.cs new file mode 100644 index 00000000..837a0b44 --- /dev/null +++ b/PowerForge/Models/ModuleBuildHostExportRequest.cs @@ -0,0 +1,27 @@ +namespace PowerForge; + +/// +/// Host-facing request for exporting module pipeline JSON via a module build script. +/// +public sealed class ModuleBuildHostExportRequest +{ + /// + /// Repository root used as the command working directory. + /// + public string RepositoryRoot { get; set; } = string.Empty; + + /// + /// Path to the repository's Build-Module.ps1 script. + /// + public string ScriptPath { get; set; } = string.Empty; + + /// + /// Path to the PSPublishModule manifest that should be imported. + /// + public string ModulePath { get; set; } = string.Empty; + + /// + /// Output path for the exported JSON file. + /// + public string OutputPath { get; set; } = string.Empty; +} diff --git a/PowerForge/Models/ModuleManifestMetadata.cs b/PowerForge/Models/ModuleManifestMetadata.cs new file mode 100644 index 00000000..90dc9254 --- /dev/null +++ b/PowerForge/Models/ModuleManifestMetadata.cs @@ -0,0 +1,32 @@ +namespace PowerForge; + +/// +/// Minimal metadata read from a PowerShell module manifest. +/// +public sealed class ModuleManifestMetadata +{ + /// + /// Initializes a new instance of the class. + /// + public ModuleManifestMetadata(string moduleName, string moduleVersion, string? preRelease) + { + ModuleName = moduleName; + ModuleVersion = moduleVersion; + PreRelease = preRelease; + } + + /// + /// Gets the resolved module name. + /// + public string ModuleName { get; } + + /// + /// Gets the resolved module version. + /// + public string ModuleVersion { get; } + + /// + /// Gets the prerelease label, when present. + /// + public string? PreRelease { get; } +} diff --git a/PowerForge/Models/PowerShellRepositoryResolution.cs b/PowerForge/Models/PowerShellRepositoryResolution.cs new file mode 100644 index 00000000..2714f449 --- /dev/null +++ b/PowerForge/Models/PowerShellRepositoryResolution.cs @@ -0,0 +1,27 @@ +namespace PowerForge; + +/// +/// Resolved PowerShell repository metadata used for verification and publish probes. +/// +public sealed class PowerShellRepositoryResolution +{ + /// + /// Repository name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Repository source URI when available. + /// + public string? SourceUri { get; set; } + + /// + /// Repository publish URI when available. + /// + public string? PublishUri { get; set; } + + /// + /// Preferred display/source URI for diagnostics. + /// + public string DisplaySource => SourceUri ?? PublishUri ?? Name; +} diff --git a/PowerForge/Models/ProjectBuildCommandBuildRequest.cs b/PowerForge/Models/ProjectBuildCommandBuildRequest.cs new file mode 100644 index 00000000..8e6cae26 --- /dev/null +++ b/PowerForge/Models/ProjectBuildCommandBuildRequest.cs @@ -0,0 +1,16 @@ +namespace PowerForge; + +/// +/// Request for executing a project build through the PowerShell-host fallback path. +/// +public sealed class ProjectBuildCommandBuildRequest +{ + /// Repository root used as the working directory. + public string RepositoryRoot { get; set; } = string.Empty; + + /// Optional project build config path. + public string? ConfigPath { get; set; } + + /// Resolved PSPublishModule path used for Import-Module. + public string ModulePath { get; set; } = string.Empty; +} diff --git a/PowerForge/Models/ProjectBuildCommandHostExecutionResult.cs b/PowerForge/Models/ProjectBuildCommandHostExecutionResult.cs new file mode 100644 index 00000000..5f614410 --- /dev/null +++ b/PowerForge/Models/ProjectBuildCommandHostExecutionResult.cs @@ -0,0 +1,25 @@ +namespace PowerForge; + +/// +/// Result returned by the PowerShell-host fallback for project build plan/build execution. +/// +public sealed class ProjectBuildCommandHostExecutionResult +{ + /// Exit code returned by the PowerShell host. + public int ExitCode { get; set; } + + /// Total execution duration. + public TimeSpan Duration { get; set; } + + /// Captured standard output. + public string StandardOutput { get; set; } = string.Empty; + + /// Captured standard error. + public string StandardError { get; set; } = string.Empty; + + /// PowerShell executable that was used. + public string Executable { get; set; } = string.Empty; + + /// Whether the command exited successfully. + public bool Succeeded => ExitCode == 0; +} diff --git a/PowerForge/Models/ProjectBuildCommandPlanRequest.cs b/PowerForge/Models/ProjectBuildCommandPlanRequest.cs new file mode 100644 index 00000000..e3dd9edb --- /dev/null +++ b/PowerForge/Models/ProjectBuildCommandPlanRequest.cs @@ -0,0 +1,19 @@ +namespace PowerForge; + +/// +/// Request for generating a project build plan through the PowerShell-host fallback path. +/// +public sealed class ProjectBuildCommandPlanRequest +{ + /// Repository root used as the working directory. + public string RepositoryRoot { get; set; } = string.Empty; + + /// Output path for the generated plan JSON. + public string PlanOutputPath { get; set; } = string.Empty; + + /// Optional project build config path. + public string? ConfigPath { get; set; } + + /// Resolved PSPublishModule path used for Import-Module. + public string ModulePath { get; set; } = string.Empty; +} diff --git a/PowerForge/Models/ProjectBuildHostExecutionResult.cs b/PowerForge/Models/ProjectBuildHostExecutionResult.cs new file mode 100644 index 00000000..0fc08128 --- /dev/null +++ b/PowerForge/Models/ProjectBuildHostExecutionResult.cs @@ -0,0 +1,57 @@ +namespace PowerForge; + +/// +/// Result returned by the host-facing project build service. +/// +public sealed class ProjectBuildHostExecutionResult +{ + /// + /// True when the requested operation completed successfully. + /// + public bool Success { get; set; } + + /// + /// Optional error message when is false. + /// + public string? ErrorMessage { get; set; } + + /// + /// Full path to the configuration file used for this execution. + /// + public string ConfigPath { get; set; } = string.Empty; + + /// + /// Resolved project/repository root used by the release workflow. + /// + public string RootPath { get; set; } = string.Empty; + + /// + /// Resolved staging path, when configured. + /// + public string? StagingPath { get; set; } + + /// + /// Resolved package output path, when configured. + /// + public string? OutputPath { get; set; } + + /// + /// Resolved release-zip output path, when configured. + /// + public string? ReleaseZipOutputPath { get; set; } + + /// + /// Resolved plan output path, when configured. + /// + public string? PlanOutputPath { get; set; } + + /// + /// Duration of the host operation. + /// + public TimeSpan Duration { get; set; } + + /// + /// Underlying project build result. + /// + public ProjectBuildResult Result { get; set; } = new(); +} diff --git a/PowerForge/Models/ProjectBuildHostRequest.cs b/PowerForge/Models/ProjectBuildHostRequest.cs new file mode 100644 index 00000000..baafd518 --- /dev/null +++ b/PowerForge/Models/ProjectBuildHostRequest.cs @@ -0,0 +1,48 @@ +namespace PowerForge; + +/// +/// Host-facing request for executing or planning a project build using a project.build.json file. +/// +public sealed class ProjectBuildHostRequest +{ + /// + /// Path to the project.build.json configuration file. + /// + public string ConfigPath { get; set; } = string.Empty; + + /// + /// Optional path where the generated plan JSON should be written. + /// + public string? PlanOutputPath { get; set; } + + /// + /// When true, executes the real build path after the planning pass. + /// When false, only the what-if/plan pass runs. + /// + public bool ExecuteBuild { get; set; } + + /// + /// Optional override for plan-only mode. + /// + public bool? PlanOnly { get; set; } + + /// + /// Optional override for version updates. + /// + public bool? UpdateVersions { get; set; } + + /// + /// Optional override for building/packing projects. + /// + public bool? Build { get; set; } + + /// + /// Optional override for NuGet publishing. + /// + public bool? PublishNuget { get; set; } + + /// + /// Optional override for GitHub release publishing. + /// + public bool? PublishGitHub { get; set; } +} diff --git a/PowerForge/Models/ProjectBuildPublishHostConfiguration.cs b/PowerForge/Models/ProjectBuildPublishHostConfiguration.cs new file mode 100644 index 00000000..ad0c92aa --- /dev/null +++ b/PowerForge/Models/ProjectBuildPublishHostConfiguration.cs @@ -0,0 +1,58 @@ +namespace PowerForge; + +/// +/// Host-facing project publish configuration resolved from project.build.json. +/// +public sealed class ProjectBuildPublishHostConfiguration +{ + /// Resolved configuration path. + public string ConfigPath { get; set; } = string.Empty; + + /// Whether NuGet publishing is enabled. + public bool PublishNuget { get; set; } + + /// Whether GitHub publishing is enabled. + public bool PublishGitHub { get; set; } + + /// Resolved NuGet publish source. + public string PublishSource { get; set; } = "https://api.nuget.org/v3/index.json"; + + /// Resolved NuGet API key. + public string? PublishApiKey { get; set; } + + /// Resolved GitHub token. + public string? GitHubToken { get; set; } + + /// Configured GitHub owner. + public string? GitHubUsername { get; set; } + + /// Configured GitHub repository name. + public string? GitHubRepositoryName { get; set; } + + /// Whether the GitHub release should be marked as a prerelease. + public bool GitHubIsPreRelease { get; set; } + + /// Whether default tags should include the project name. + public bool GitHubIncludeProjectNameInTag { get; set; } = true; + + /// Whether GitHub should generate release notes. + public bool GitHubGenerateReleaseNotes { get; set; } + + /// Configured GitHub release name override or template. + public string? GitHubReleaseName { get; set; } + + /// Configured GitHub tag override. + public string? GitHubTagName { get; set; } + + /// Configured GitHub tag template. + public string? GitHubTagTemplate { get; set; } + + /// Configured GitHub release mode. + public string GitHubReleaseMode { get; set; } = "Single"; + + /// Configured GitHub primary project. + public string? GitHubPrimaryProject { get; set; } + + /// Configured GitHub tag conflict policy. + public string? GitHubTagConflictPolicy { get; set; } +} diff --git a/PowerForge/Models/PublishVerificationRequest.cs b/PowerForge/Models/PublishVerificationRequest.cs new file mode 100644 index 00000000..11a3bb78 --- /dev/null +++ b/PowerForge/Models/PublishVerificationRequest.cs @@ -0,0 +1,28 @@ +namespace PowerForge; + +/// +/// Host-facing request describing a published target that should be verified. +/// +public sealed class PublishVerificationRequest +{ + /// Repository root used for local file and repository resolution. + public string RootPath { get; set; } = string.Empty; + + /// Repository display name. + public string RepositoryName { get; set; } = string.Empty; + + /// Adapter kind that produced the published target. + public string AdapterKind { get; set; } = string.Empty; + + /// Target display name. + public string TargetName { get; set; } = string.Empty; + + /// Target kind such as GitHub, NuGet, or PowerShellRepository. + public string TargetKind { get; set; } = string.Empty; + + /// Recorded destination string for the publish target. + public string? Destination { get; set; } + + /// Recorded local source path used during publish. + public string? SourcePath { get; set; } +} diff --git a/PowerForge/Models/PublishVerificationResult.cs b/PowerForge/Models/PublishVerificationResult.cs new file mode 100644 index 00000000..5240b1da --- /dev/null +++ b/PowerForge/Models/PublishVerificationResult.cs @@ -0,0 +1,13 @@ +namespace PowerForge; + +/// +/// Result returned by the shared publish verification host service. +/// +public sealed class PublishVerificationResult +{ + /// Verification outcome. + public PublishVerificationStatus Status { get; set; } + + /// Human-readable verification summary. + public string Summary { get; set; } = string.Empty; +} diff --git a/PowerForge/Models/PublishVerificationStatus.cs b/PowerForge/Models/PublishVerificationStatus.cs new file mode 100644 index 00000000..d191ddc9 --- /dev/null +++ b/PowerForge/Models/PublishVerificationStatus.cs @@ -0,0 +1,16 @@ +namespace PowerForge; + +/// +/// Outcome of a shared publish verification probe. +/// +public enum PublishVerificationStatus +{ + /// The published target was verified successfully. + Verified, + + /// The published target could not be verified and should be treated as a failure. + Failed, + + /// Verification was intentionally skipped because no reliable probe could be derived. + Skipped +} diff --git a/PowerForge/Models/ReleaseSigningHostSettings.cs b/PowerForge/Models/ReleaseSigningHostSettings.cs new file mode 100644 index 00000000..dd4d4d12 --- /dev/null +++ b/PowerForge/Models/ReleaseSigningHostSettings.cs @@ -0,0 +1,25 @@ +namespace PowerForge; + +/// +/// Host-facing signing settings resolved for Authenticode and NuGet signing workflows. +/// +public sealed class ReleaseSigningHostSettings +{ + /// Whether signing is configured well enough to run. + public bool IsConfigured { get; set; } + + /// Resolved signing certificate thumbprint. + public string? Thumbprint { get; set; } + + /// Resolved certificate store name. + public string StoreName { get; set; } = "CurrentUser"; + + /// Resolved timestamp server URL. + public string TimeStampServer { get; set; } = "http://timestamp.digicert.com"; + + /// Resolved PSPublishModule path used for shared PowerShell-host signing commands. + public string ModulePath { get; set; } = string.Empty; + + /// Failure message when signing is not configured. + public string? MissingConfigurationMessage { get; set; } +} diff --git a/PowerForge/Models/RunnerHousekeeping.cs b/PowerForge/Models/RunnerHousekeeping.cs new file mode 100644 index 00000000..941c6ac3 --- /dev/null +++ b/PowerForge/Models/RunnerHousekeeping.cs @@ -0,0 +1,242 @@ +using System; + +namespace PowerForge; + +/// +/// Specification for reclaiming disk space on a GitHub Actions runner. +/// +public sealed class RunnerHousekeepingSpec +{ + /// + /// Optional explicit runner temp directory. When omitted, RUNNER_TEMP is used. + /// + public string? RunnerTempPath { get; set; } + + /// + /// Optional explicit work root. When omitted, it is derived from the runner temp path or GITHUB_WORKSPACE. + /// + public string? WorkRootPath { get; set; } + + /// + /// Optional explicit runner root. When omitted, it is derived from the work root. + /// + public string? RunnerRootPath { get; set; } + + /// + /// Optional explicit diagnostics root. When omitted, <runnerRoot>/_diag is used. + /// + public string? DiagnosticsRootPath { get; set; } + + /// + /// Optional explicit tool cache path. When omitted, RUNNER_TOOL_CACHE or AGENT_TOOLSDIRECTORY is used. + /// + public string? ToolCachePath { get; set; } + + /// + /// Minimum free disk size, in GiB, required after cleanup. Set to null to disable the threshold. + /// + public int? MinFreeGb { get; set; } = 20; + + /// + /// Free disk threshold, in GiB, below which aggressive cleanup is enabled. + /// When omitted, the service uses MinFreeGb + 5. + /// + public int? AggressiveThresholdGb { get; set; } + + /// + /// Retention window for runner diagnostics files. + /// + public int DiagnosticsRetentionDays { get; set; } = 14; + + /// + /// Retention window for cached GitHub action working sets under _actions. + /// + public int ActionsRetentionDays { get; set; } = 7; + + /// + /// Retention window for runner tool cache directories. + /// + public int ToolCacheRetentionDays { get; set; } = 30; + + /// + /// When true, only plans deletions and external commands without executing them. + /// + public bool DryRun { get; set; } = true; + + /// + /// Forces aggressive cleanup even when free disk is above the threshold. + /// + public bool Aggressive { get; set; } + + /// + /// When true, runner diagnostics cleanup is enabled. + /// + public bool CleanDiagnostics { get; set; } = true; + + /// + /// When true, runner temp contents are cleaned. + /// + public bool CleanRunnerTemp { get; set; } = true; + + /// + /// When true, GitHub actions working sets under _actions are cleaned during aggressive cleanup. + /// + public bool CleanActionsCache { get; set; } = true; + + /// + /// When true, runner tool cache directories are cleaned during aggressive cleanup. + /// + public bool CleanToolCache { get; set; } = true; + + /// + /// When true, dotnet nuget locals all --clear is executed during aggressive cleanup. + /// + public bool ClearDotNetCaches { get; set; } = true; + + /// + /// When true, docker system prune is executed during aggressive cleanup. + /// + public bool PruneDocker { get; set; } = true; + + /// + /// When true, Docker volumes are included in the prune operation. + /// + public bool IncludeDockerVolumes { get; set; } = true; + + /// + /// When true, the service may use sudo for directory deletion on Unix when direct deletion fails. + /// + public bool AllowSudo { get; set; } +} + +/// +/// Single cleanup step included in runner housekeeping output. +/// +public sealed class RunnerHousekeepingStepResult +{ + /// + /// Stable step identifier. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Human-readable step title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Whether the step was skipped. + /// + public bool Skipped { get; set; } + + /// + /// Whether the step was a dry-run preview. + /// + public bool DryRun { get; set; } + + /// + /// Whether the step completed successfully. + /// + public bool Success { get; set; } = true; + + /// + /// Optional step message. + /// + public string? Message { get; set; } + + /// + /// Number of filesystem entries deleted or planned. + /// + public int EntriesAffected { get; set; } + + /// + /// Optional command text executed by the step. + /// + public string? Command { get; set; } + + /// + /// Process exit code when the step runs an external command. + /// + public int? ExitCode { get; set; } + + /// + /// Filesystem paths touched or planned by the step. + /// + public string[] Targets { get; set; } = Array.Empty(); +} + +/// +/// Result summary for a runner housekeeping run. +/// +public sealed class RunnerHousekeepingResult +{ + /// + /// Runner root used by the cleanup run. + /// + public string RunnerRootPath { get; set; } = string.Empty; + + /// + /// Work root used by the cleanup run. + /// + public string WorkRootPath { get; set; } = string.Empty; + + /// + /// Runner temp path used by the cleanup run. + /// + public string RunnerTempPath { get; set; } = string.Empty; + + /// + /// Optional diagnostics root used by the cleanup run. + /// + public string? DiagnosticsRootPath { get; set; } + + /// + /// Optional tool cache path used by the cleanup run. + /// + public string? ToolCachePath { get; set; } + + /// + /// Free disk before cleanup, in bytes. + /// + public long FreeBytesBefore { get; set; } + + /// + /// Free disk after cleanup, in bytes. + /// + public long FreeBytesAfter { get; set; } + + /// + /// Minimum required free disk after cleanup, in bytes. + /// + public long? RequiredFreeBytes { get; set; } + + /// + /// Aggressive threshold used by the run, in bytes. + /// + public long? AggressiveThresholdBytes { get; set; } + + /// + /// Whether aggressive cleanup was executed. + /// + public bool AggressiveApplied { get; set; } + + /// + /// Whether run executed in dry-run mode. + /// + public bool DryRun { get; set; } + + /// + /// Whether the run completed successfully. + /// + public bool Success { get; set; } = true; + + /// + /// Optional warning or error message. + /// + public string? Message { get; set; } + + /// + /// Detailed step results. + /// + public RunnerHousekeepingStepResult[] Steps { get; set; } = Array.Empty(); +} diff --git a/PowerForge/PowerForge.csproj b/PowerForge/PowerForge.csproj index 323868c3..bede72e6 100644 --- a/PowerForge/PowerForge.csproj +++ b/PowerForge/PowerForge.csproj @@ -8,9 +8,17 @@ true CS1591 1.0.0 + PowerForge PowerForge core library: reusable engine for building, formatting, packaging and publishing PowerShell modules. PowerForge PowerForge + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + powershell;build;publishing;automation;github + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge/Services/AuthenticodeSigningHostService.cs b/PowerForge/Services/AuthenticodeSigningHostService.cs new file mode 100644 index 00000000..d7c140e6 --- /dev/null +++ b/PowerForge/Services/AuthenticodeSigningHostService.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; + +namespace PowerForge; + +/// +/// Shared host service for invoking Register-Certificate through PSPublishModule. +/// +public sealed class AuthenticodeSigningHostService +{ + private readonly IPowerShellRunner _powerShellRunner; + + /// + /// Creates a new signing host service using the default PowerShell runner. + /// + public AuthenticodeSigningHostService() + : this(new PowerShellRunner()) + { + } + + internal AuthenticodeSigningHostService(IPowerShellRunner powerShellRunner) + { + _powerShellRunner = powerShellRunner ?? throw new ArgumentNullException(nameof(powerShellRunner)); + } + + /// + /// Executes Authenticode signing for the requested path and include patterns. + /// + public async Task SignAsync(AuthenticodeSigningHostRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ValidateRequired(request.SigningPath, nameof(request.SigningPath)); + ValidateRequired(request.ModulePath, nameof(request.ModulePath)); + ValidateRequired(request.Thumbprint, nameof(request.Thumbprint)); + ValidateRequired(request.StoreName, nameof(request.StoreName)); + ValidateRequired(request.TimeStampServer, nameof(request.TimeStampServer)); + + var includes = string.Join(", ", (request.IncludePatterns ?? Array.Empty()).Select(QuoteLiteral)); + var script = string.Join("; ", new[] { + "$ErrorActionPreference = 'Stop'", + BuildModuleImportClause(request.ModulePath), + $"Register-Certificate -Path {QuoteLiteral(request.SigningPath)} -LocalStore {request.StoreName} -Thumbprint {QuoteLiteral(request.Thumbprint)} -TimeStampServer {QuoteLiteral(request.TimeStampServer)} -Include @({includes}) -Confirm:$false -WarningAction Stop -ErrorAction Stop | Out-Null" + }); + + var startedAt = Stopwatch.StartNew(); + var result = await Task.Run(() => _powerShellRunner.Run(PowerShellRunRequest.ForCommand( + commandText: script, + timeout: TimeSpan.FromMinutes(15), + preferPwsh: !OperatingSystem.IsWindows(), + workingDirectory: request.SigningPath, + executableOverride: Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"))), cancellationToken).ConfigureAwait(false); + startedAt.Stop(); + + return new AuthenticodeSigningHostResult { + ExitCode = result.ExitCode, + Duration = startedAt.Elapsed, + StandardOutput = result.StdOut, + StandardError = result.StdErr, + Executable = result.Executable + }; + } + + private static string BuildModuleImportClause(string modulePath) + => File.Exists(modulePath) + ? $"try {{ Import-Module {QuoteLiteral(modulePath)} -Force -ErrorAction Stop }} catch {{ Import-Module PSPublishModule -Force -ErrorAction Stop }}" + : "Import-Module PSPublishModule -Force -ErrorAction Stop"; + + private static string QuoteLiteral(string value) + => $"'{(value ?? string.Empty).Replace("'", "''")}'"; + + private static void ValidateRequired(string value, string argumentName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException($"{argumentName} is required.", argumentName); + } +} diff --git a/PowerForge/Services/CertificateFingerprintResolver.cs b/PowerForge/Services/CertificateFingerprintResolver.cs new file mode 100644 index 00000000..b87e280b --- /dev/null +++ b/PowerForge/Services/CertificateFingerprintResolver.cs @@ -0,0 +1,62 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace PowerForge; + +/// +/// Resolves SHA256 certificate fingerprints from the local certificate store. +/// +public sealed class CertificateFingerprintResolver +{ + private readonly Func _resolveSha256; + + /// + /// Creates a new resolver using the local certificate store. + /// + public CertificateFingerprintResolver() + : this(ResolveSha256Core) + { + } + + internal CertificateFingerprintResolver(Func resolveSha256) + { + _resolveSha256 = resolveSha256 ?? throw new ArgumentNullException(nameof(resolveSha256)); + } + + /// + /// Resolves a SHA256 fingerprint for the provided thumbprint and store name. + /// + public string? ResolveSha256(string thumbprint, string storeName) + { + if (string.IsNullOrWhiteSpace(thumbprint)) + throw new ArgumentException("Thumbprint is required.", nameof(thumbprint)); + if (string.IsNullOrWhiteSpace(storeName)) + throw new ArgumentException("StoreName is required.", nameof(storeName)); + + try + { + return _resolveSha256(ParseStoreLocation(storeName), NormalizeThumbprint(thumbprint)); + } + catch + { + return null; + } + } + + private static string? ResolveSha256Core(StoreLocation storeLocation, string normalizedThumbprint) + { + using var store = new X509Store(StoreName.My, storeLocation); + store.Open(OpenFlags.ReadOnly); + var certificate = store.Certificates.Cast() + .FirstOrDefault(candidate => NormalizeThumbprint(candidate.Thumbprint) == normalizedThumbprint); + return certificate?.GetCertHashString(HashAlgorithmName.SHA256); + } + + private static StoreLocation ParseStoreLocation(string storeName) + => string.Equals(storeName, "LocalMachine", StringComparison.OrdinalIgnoreCase) + ? StoreLocation.LocalMachine + : StoreLocation.CurrentUser; + + private static string NormalizeThumbprint(string? thumbprint) + => (thumbprint ?? string.Empty).Replace(" ", string.Empty).ToUpperInvariant(); +} diff --git a/PowerForge/Services/DotNetNuGetClient.cs b/PowerForge/Services/DotNetNuGetClient.cs new file mode 100644 index 00000000..2914a021 --- /dev/null +++ b/PowerForge/Services/DotNetNuGetClient.cs @@ -0,0 +1,194 @@ +namespace PowerForge; + +/// +/// Reusable typed client for dotnet nuget operations. +/// +public sealed class DotNetNuGetClient +{ + private readonly IProcessRunner _processRunner; + private readonly string _dotNetExecutable; + private readonly TimeSpan _defaultTimeout; + private readonly string _runtimeDirectoryRoot; + + /// + /// Initializes a new instance of the class. + /// + /// Optional process runner implementation. + /// Optional dotnet executable name or path. + /// Optional default timeout. + /// Optional runtime directory root for temporary response files. + public DotNetNuGetClient( + IProcessRunner? processRunner = null, + string dotNetExecutable = "dotnet", + TimeSpan? defaultTimeout = null, + string? runtimeDirectoryRoot = null) + { + _processRunner = processRunner ?? new ProcessRunner(); + _dotNetExecutable = string.IsNullOrWhiteSpace(dotNetExecutable) ? "dotnet" : dotNetExecutable; + _defaultTimeout = defaultTimeout ?? TimeSpan.FromMinutes(10); + _runtimeDirectoryRoot = string.IsNullOrWhiteSpace(runtimeDirectoryRoot) + ? Path.Combine(Path.GetTempPath(), "PowerForge", "runtime", "dotnet-nuget") + : runtimeDirectoryRoot; + } + + /// + /// Executes dotnet nuget push using a temporary response file. + /// + /// Push request. + /// Cancellation token. + /// Structured push result. + public async Task PushPackageAsync(DotNetNuGetPushRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.PackagePath)) + throw new ArgumentException("PackagePath is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.ApiKey)) + throw new ArgumentException("ApiKey is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.Source)) + throw new ArgumentException("Source is required.", nameof(request)); + + Directory.CreateDirectory(_runtimeDirectoryRoot); + var responseFilePath = Path.Combine(_runtimeDirectoryRoot, $"nuget-push-{Guid.NewGuid():N}.rsp"); + + try + { + File.WriteAllText(responseFilePath, BuildPushResponseFileContent(request)); + + var processResult = await _processRunner.RunAsync( + new ProcessRunRequest( + _dotNetExecutable, + ResolveWorkingDirectory(request.WorkingDirectory, request.PackagePath), + [$"@{responseFilePath}"], + request.Timeout ?? _defaultTimeout), + cancellationToken).ConfigureAwait(false); + + return new DotNetNuGetPushResult( + processResult.ExitCode, + processResult.StdOut, + processResult.StdErr, + processResult.Executable, + processResult.Duration, + processResult.TimedOut, + processResult.ExitCode == 0 && !processResult.TimedOut + ? null + : FirstLine(processResult.StdErr) ?? FirstLine(processResult.StdOut) ?? $"dotnet nuget push failed with exit code {processResult.ExitCode}."); + } + finally + { + TryDeleteFile(responseFilePath); + } + } + + /// + /// Executes dotnet nuget sign. + /// + /// Sign request. + /// Cancellation token. + /// Structured sign result. + public async Task SignPackageAsync(DotNetNuGetSignRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.PackagePath)) + throw new ArgumentException("PackagePath is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.CertificateFingerprint)) + throw new ArgumentException("CertificateFingerprint is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.CertificateStoreLocation)) + throw new ArgumentException("CertificateStoreLocation is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.CertificateStoreName)) + throw new ArgumentException("CertificateStoreName is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.TimeStampServer)) + throw new ArgumentException("TimeStampServer is required.", nameof(request)); + + var arguments = new List { + "nuget", + "sign", + request.PackagePath, + "--certificate-fingerprint", + request.CertificateFingerprint, + "--certificate-store-location", + request.CertificateStoreLocation, + "--certificate-store-name", + request.CertificateStoreName, + "--timestamper", + request.TimeStampServer + }; + + if (request.Overwrite) + arguments.Add("--overwrite"); + + var processResult = await _processRunner.RunAsync( + new ProcessRunRequest( + _dotNetExecutable, + ResolveWorkingDirectory(request.WorkingDirectory, request.PackagePath), + arguments, + request.Timeout ?? _defaultTimeout), + cancellationToken).ConfigureAwait(false); + + return new DotNetNuGetSignResult( + processResult.ExitCode, + processResult.StdOut, + processResult.StdErr, + processResult.Executable, + processResult.Duration, + processResult.TimedOut, + processResult.ExitCode == 0 && !processResult.TimedOut + ? null + : FirstLine(processResult.StdErr) ?? FirstLine(processResult.StdOut) ?? $"dotnet nuget sign failed with exit code {processResult.ExitCode}."); + } + + private static string BuildPushResponseFileContent(DotNetNuGetPushRequest request) + { + var lines = new List { + "nuget", + "push", + QuoteResponseFileValue(request.PackagePath), + "--api-key", + QuoteResponseFileValue(request.ApiKey), + "--source", + QuoteResponseFileValue(request.Source) + }; + + if (request.SkipDuplicate) + lines.Add("--skip-duplicate"); + + return string.Join(Environment.NewLine, lines); + } + + private static string ResolveWorkingDirectory(string? workingDirectory, string packagePath) + { + if (!string.IsNullOrWhiteSpace(workingDirectory)) + return workingDirectory; + + var packageDirectory = Path.GetDirectoryName(packagePath); + if (!string.IsNullOrWhiteSpace(packageDirectory)) + return packageDirectory; + + return Environment.CurrentDirectory; + } + + private static string QuoteResponseFileValue(string value) + => "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // Best-effort cleanup only. + } + } + + private static string? FirstLine(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + return value.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); + } +} diff --git a/PowerForge/Services/DotNetRepositoryReleaseService.Execute.cs b/PowerForge/Services/DotNetRepositoryReleaseService.Execute.cs index ba752808..47367b22 100644 --- a/PowerForge/Services/DotNetRepositoryReleaseService.Execute.cs +++ b/PowerForge/Services/DotNetRepositoryReleaseService.Execute.cs @@ -120,13 +120,14 @@ public DotNetRepositoryReleaseResult Execute(DotNetRepositoryReleaseSpec spec) var dupPaths = string.Join("; ", group.Select(g => g.Path)); foreach (var item in group) { - projects.Add(new DotNetRepositoryProjectResult - { - ProjectName = item.Name, - CsprojPath = item.Path, - IsPackable = IsPackable(item.Path), - ErrorMessage = $"Duplicate project name found in multiple paths: {dupPaths}. Exclude directories or rename projects." - }); + projects.Add(new DotNetRepositoryProjectResult + { + ProjectName = item.Name, + CsprojPath = item.Path, + PackageId = ResolvePackageId(item.Path, item.Name), + IsPackable = IsPackable(item.Path), + ErrorMessage = $"Duplicate project name found in multiple paths: {dupPaths}. Exclude directories or rename projects." + }); } result.Success = false; _logger.Warn($"Duplicate project name '{group.Key}' found in multiple paths: {dupPaths}"); @@ -134,12 +135,13 @@ public DotNetRepositoryReleaseResult Execute(DotNetRepositoryReleaseSpec spec) } var entry = group.First(); - projects.Add(new DotNetRepositoryProjectResult - { - ProjectName = entry.Name, - CsprojPath = entry.Path, - IsPackable = IsPackable(entry.Path) - }); + projects.Add(new DotNetRepositoryProjectResult + { + ProjectName = entry.Name, + CsprojPath = entry.Path, + PackageId = ResolvePackageId(entry.Path, entry.Name), + IsPackable = IsPackable(entry.Path) + }); } if (projects.Count == 0) @@ -298,7 +300,7 @@ public DotNetRepositoryReleaseResult Execute(DotNetRepositoryReleaseSpec spec) continue; } - var filtered = FilterPackages(packResult.Packages, project.ProjectName, project.NewVersion!); + var filtered = FilterPackages(packResult.Packages, project.PackageId, project.NewVersion!); if (filtered.Count == 0) { project.ErrorMessage = $"No packages found for version {project.NewVersion}."; diff --git a/PowerForge/Services/DotNetRepositoryReleaseService.ExpectedAndZip.cs b/PowerForge/Services/DotNetRepositoryReleaseService.ExpectedAndZip.cs index f27be375..2f59ae42 100644 --- a/PowerForge/Services/DotNetRepositoryReleaseService.ExpectedAndZip.cs +++ b/PowerForge/Services/DotNetRepositoryReleaseService.ExpectedAndZip.cs @@ -27,11 +27,11 @@ private static Dictionary BuildExpectedVersionMap(Dictionary e.Name.LocalName.Equals("IsPackable", StringComparison.OrdinalIgnoreCase)) ?.Value; @@ -42,19 +42,39 @@ private static bool IsPackable(string csprojPath) catch { return true; - } - } - - private static string BuildReleaseZipPath(DotNetRepositoryProjectResult project, DotNetRepositoryReleaseSpec spec) - { - var csprojDir = Path.GetDirectoryName(project.CsprojPath) ?? string.Empty; - var cfg = string.IsNullOrWhiteSpace(spec.Configuration) ? "Release" : spec.Configuration.Trim(); - var releasePath = string.IsNullOrWhiteSpace(spec.ReleaseZipOutputPath) - ? Path.Combine(csprojDir, "bin", cfg) - : spec.ReleaseZipOutputPath!; - var version = string.IsNullOrWhiteSpace(project.NewVersion) ? "0.0.0" : project.NewVersion; - return Path.Combine(releasePath, $"{project.ProjectName}.{version}.zip"); - } + } + } + + private static string ResolvePackageId(string csprojPath, string fallbackProjectName) + { + try + { + var doc = XDocument.Load(csprojPath); + var packageId = doc.Descendants() + .FirstOrDefault(e => e.Name.LocalName.Equals("PackageId", StringComparison.OrdinalIgnoreCase)) + ?.Value; + + return string.IsNullOrWhiteSpace(packageId) + ? fallbackProjectName + : (packageId ?? string.Empty).Trim(); + } + catch + { + return fallbackProjectName; + } + } + + private static string BuildReleaseZipPath(DotNetRepositoryProjectResult project, DotNetRepositoryReleaseSpec spec) + { + var csprojDir = Path.GetDirectoryName(project.CsprojPath) ?? string.Empty; + var cfg = string.IsNullOrWhiteSpace(spec.Configuration) ? "Release" : spec.Configuration.Trim(); + var releasePath = string.IsNullOrWhiteSpace(spec.ReleaseZipOutputPath) + ? Path.Combine(csprojDir, "bin", cfg) + : spec.ReleaseZipOutputPath!; + var version = string.IsNullOrWhiteSpace(project.NewVersion) ? "0.0.0" : project.NewVersion; + var assetName = string.IsNullOrWhiteSpace(project.PackageId) ? project.ProjectName : project.PackageId; + return Path.Combine(releasePath, $"{assetName}.{version}.zip"); + } private static bool TryCreateReleaseZip( DotNetRepositoryProjectResult project, diff --git a/PowerForge/Services/DotNetRepositoryReleaseService.Signing.cs b/PowerForge/Services/DotNetRepositoryReleaseService.Signing.cs index fe8efc48..8b6d7925 100644 --- a/PowerForge/Services/DotNetRepositoryReleaseService.Signing.cs +++ b/PowerForge/Services/DotNetRepositoryReleaseService.Signing.cs @@ -1,8 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; +using System; +using System.IO; +using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; @@ -37,60 +35,27 @@ private static bool SignPackages( return true; } - private static int RunDotnetSign( - string packagePath, - string sha256, - string store, + private static int RunDotnetSign( + string packagePath, + string sha256, + string store, string timeStampServer, out string stdErr, out string stdOut) - { - stdErr = string.Empty; - stdOut = string.Empty; - - var psi = new ProcessStartInfo - { - FileName = "dotnet", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - ProcessStartInfoEncoding.TryApplyUtf8(psi); - -#if NET472 - var args = new List - { - "nuget", "sign", packagePath, - "--certificate-fingerprint", sha256, - "--certificate-store-location", store, - "--certificate-store-name", "My", - "--timestamper", timeStampServer, - "--overwrite" - }; - psi.Arguments = BuildWindowsArgumentString(args); -#else - psi.ArgumentList.Add("nuget"); - psi.ArgumentList.Add("sign"); - psi.ArgumentList.Add(packagePath); - psi.ArgumentList.Add("--certificate-fingerprint"); - psi.ArgumentList.Add(sha256); - psi.ArgumentList.Add("--certificate-store-location"); - psi.ArgumentList.Add(store); - psi.ArgumentList.Add("--certificate-store-name"); - psi.ArgumentList.Add("My"); - psi.ArgumentList.Add("--timestamper"); - psi.ArgumentList.Add(timeStampServer); - psi.ArgumentList.Add("--overwrite"); -#endif - - using var p = Process.Start(psi); - if (p is null) return 1; - stdOut = p.StandardOutput.ReadToEnd(); - stdErr = p.StandardError.ReadToEnd(); - p.WaitForExit(); - return p.ExitCode; - } + { + var result = new DotNetNuGetClient() + .SignPackageAsync(new DotNetNuGetSignRequest( + packagePath: packagePath, + certificateFingerprint: sha256, + certificateStoreLocation: store, + timeStampServer: timeStampServer)) + .GetAwaiter() + .GetResult(); + + stdErr = result.StdErr; + stdOut = result.StdOut; + return result.ExitCode; + } private static string? GetCertificateSha256(string thumbprint, CertificateStoreLocation storeLocation) { diff --git a/PowerForge/Services/DotNetRepositoryReleaseService.ValidationAndSort.cs b/PowerForge/Services/DotNetRepositoryReleaseService.ValidationAndSort.cs index c620e7e3..42da372c 100644 --- a/PowerForge/Services/DotNetRepositoryReleaseService.ValidationAndSort.cs +++ b/PowerForge/Services/DotNetRepositoryReleaseService.ValidationAndSort.cs @@ -66,7 +66,7 @@ private static IReadOnlyList BuildExcludeDirectories(IEnumerable } var latest = _resolver.ResolveLatest( - packageId: project.ProjectName, + packageId: string.IsNullOrWhiteSpace(project.PackageId) ? project.ProjectName : project.PackageId, sources: versionSources, credential: spec.VersionSourceCredential, includePrerelease: spec.IncludePrerelease); diff --git a/PowerForge/Services/DotNetRepositoryReleaseService.VersionAndPacking.cs b/PowerForge/Services/DotNetRepositoryReleaseService.VersionAndPacking.cs index 47cc11f9..a38aec4b 100644 --- a/PowerForge/Services/DotNetRepositoryReleaseService.VersionAndPacking.cs +++ b/PowerForge/Services/DotNetRepositoryReleaseService.VersionAndPacking.cs @@ -39,11 +39,11 @@ private string ResolveVersion( if (Version.TryParse(expectedVersion, out var exact)) return exact.ToString(); - var current = _resolver.ResolveLatest( - packageId: project.ProjectName, - sources: spec.VersionSources, - credential: spec.VersionSourceCredential, - includePrerelease: spec.IncludePrerelease); + var current = _resolver.ResolveLatest( + packageId: string.IsNullOrWhiteSpace(project.PackageId) ? project.ProjectName : project.PackageId, + sources: spec.VersionSources, + credential: spec.VersionSourceCredential, + includePrerelease: spec.IncludePrerelease); if (current is null) warning = $"No current package version found; using 0 baseline for '{expectedVersion}'."; @@ -97,7 +97,8 @@ private static DotNetPackResult PackProject(DotNetRepositoryProjectResult projec : Path.Combine(spec.RootPath, spec.OutputPath)); if (string.IsNullOrWhiteSpace(outputPath)) return null; - return Path.Combine(outputPath, $"{project.ProjectName}.{version}.nupkg"); + var packageId = string.IsNullOrWhiteSpace(project.PackageId) ? project.ProjectName : project.PackageId; + return Path.Combine(outputPath, $"{packageId}.{version}.nupkg"); } private static int RunDotnetPack(string csproj, string workingDirectory, string configuration, string? outputPath, out string stdErr, out string stdOut) diff --git a/PowerForge/Services/GitClient.cs b/PowerForge/Services/GitClient.cs new file mode 100644 index 00000000..380df8b6 --- /dev/null +++ b/PowerForge/Services/GitClient.cs @@ -0,0 +1,254 @@ +namespace PowerForge; + +/// +/// Reusable typed client for git repository operations. +/// +public sealed class GitClient +{ + private readonly IProcessRunner _processRunner; + private readonly string _gitExecutable; + private readonly TimeSpan _defaultTimeout; + + /// + /// Initializes a new instance of the class. + /// + /// Optional process runner implementation. + /// Optional git executable name or path. + /// Optional default timeout. + public GitClient( + IProcessRunner? processRunner = null, + string gitExecutable = "git", + TimeSpan? defaultTimeout = null) + { + _processRunner = processRunner ?? new ProcessRunner(); + _gitExecutable = string.IsNullOrWhiteSpace(gitExecutable) ? "git" : gitExecutable; + _defaultTimeout = defaultTimeout ?? TimeSpan.FromSeconds(10); + } + + /// + /// Gets a parsed repository status snapshot. + /// + /// Repository working directory. + /// Cancellation token. + /// Parsed repository status snapshot. + public async Task GetStatusAsync(string repositoryRoot, CancellationToken cancellationToken = default) + { + var result = await RunAsync( + new GitCommandRequest(repositoryRoot, GitCommandKind.StatusPorcelainBranch), + cancellationToken).ConfigureAwait(false); + + if (!result.Succeeded) + return new GitStatusSnapshot(false, null, null, 0, 0, 0, 0, result); + + return ParseStatus(result); + } + + /// + /// Executes git status --short --branch. + /// + /// Repository working directory. + /// Cancellation token. + /// Typed git command result. + public Task RunStatusShortBranchAsync(string repositoryRoot, CancellationToken cancellationToken = default) + => RunAsync(new GitCommandRequest(repositoryRoot, GitCommandKind.StatusShortBranch), cancellationToken); + + /// + /// Executes git status --short. + /// + /// Repository working directory. + /// Cancellation token. + /// Typed git command result. + public Task RunStatusShortAsync(string repositoryRoot, CancellationToken cancellationToken = default) + => RunAsync(new GitCommandRequest(repositoryRoot, GitCommandKind.StatusShort), cancellationToken); + + /// + /// Executes git rev-parse --show-toplevel. + /// + /// Repository working directory. + /// Cancellation token. + /// Typed git command result. + public Task ShowTopLevelAsync(string repositoryRoot, CancellationToken cancellationToken = default) + => RunAsync(new GitCommandRequest(repositoryRoot, GitCommandKind.ShowTopLevel), cancellationToken); + + /// + /// Executes git pull --rebase. + /// + /// Repository working directory. + /// Cancellation token. + /// Typed git command result. + public Task PullRebaseAsync(string repositoryRoot, CancellationToken cancellationToken = default) + => RunAsync(new GitCommandRequest(repositoryRoot, GitCommandKind.PullRebase), cancellationToken); + + /// + /// Executes git switch -c <branch>. + /// + /// Repository working directory. + /// Branch name to create. + /// Cancellation token. + /// Typed git command result. + public Task CreateBranchAsync(string repositoryRoot, string branchName, CancellationToken cancellationToken = default) + => RunAsync(new GitCommandRequest(repositoryRoot, GitCommandKind.CreateBranch, branchName: branchName), cancellationToken); + + /// + /// Executes git push --set-upstream <remote> <branch>. + /// + /// Repository working directory. + /// Branch name to publish. + /// Remote name to use. + /// Cancellation token. + /// Typed git command result. + public Task SetUpstreamAsync( + string repositoryRoot, + string branchName, + string remoteName = "origin", + CancellationToken cancellationToken = default) + => RunAsync( + new GitCommandRequest(repositoryRoot, GitCommandKind.SetUpstream, branchName: branchName, remoteName: remoteName), + cancellationToken); + + /// + /// Executes git remote get-url <remote>. + /// + /// Repository working directory. + /// Remote name to inspect. + /// Cancellation token. + /// Typed git command result. + public Task GetRemoteUrlAsync( + string repositoryRoot, + string remoteName = "origin", + CancellationToken cancellationToken = default) + => RunAsync( + new GitCommandRequest(repositoryRoot, GitCommandKind.GetRemoteUrl, remoteName: remoteName), + cancellationToken); + + /// + /// Executes a typed git command. + /// + /// Typed git command request. + /// Cancellation token. + /// Typed git command result. + public async Task RunAsync(GitCommandRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.WorkingDirectory)) + throw new ArgumentException("Working directory is required.", nameof(request)); + + var (arguments, displayCommand) = BuildArguments(request); + var result = await _processRunner.RunAsync( + new ProcessRunRequest( + _gitExecutable, + request.WorkingDirectory, + arguments, + request.Timeout ?? _defaultTimeout), + cancellationToken).ConfigureAwait(false); + + return new GitCommandResult( + request.CommandKind, + request.WorkingDirectory, + displayCommand, + result.ExitCode, + result.StdOut, + result.StdErr, + result.Executable, + result.Duration, + result.TimedOut); + } + + private static GitStatusSnapshot ParseStatus(GitCommandResult result) + { + string? branchName = null; + string? upstreamBranch = null; + var aheadCount = 0; + var behindCount = 0; + var trackedChangeCount = 0; + var untrackedChangeCount = 0; + + foreach (var line in result.StdOut.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("# branch.head ", StringComparison.Ordinal)) + { + branchName = line["# branch.head ".Length..].Trim(); + continue; + } + + if (line.StartsWith("# branch.upstream ", StringComparison.Ordinal)) + { + upstreamBranch = line["# branch.upstream ".Length..].Trim(); + continue; + } + + if (line.StartsWith("# branch.ab ", StringComparison.Ordinal)) + { + ParseAheadBehind(line["# branch.ab ".Length..], out aheadCount, out behindCount); + continue; + } + + if (line.StartsWith("? ", StringComparison.Ordinal)) + { + untrackedChangeCount++; + continue; + } + + if (line.StartsWith("1 ", StringComparison.Ordinal) + || line.StartsWith("2 ", StringComparison.Ordinal) + || line.StartsWith("u ", StringComparison.Ordinal)) + { + trackedChangeCount++; + } + } + + return new GitStatusSnapshot(true, branchName, upstreamBranch, aheadCount, behindCount, trackedChangeCount, untrackedChangeCount, result); + } + + private static void ParseAheadBehind(string value, out int aheadCount, out int behindCount) + { + aheadCount = 0; + behindCount = 0; + + var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + foreach (var part in parts) + { + if (part.StartsWith('+') && int.TryParse(part[1..], out var ahead)) + aheadCount = ahead; + + if (part.StartsWith('-') && int.TryParse(part[1..], out var behind)) + behindCount = behind; + } + } + + private static (IReadOnlyList Arguments, string DisplayCommand) BuildArguments(GitCommandRequest request) + { + switch (request.CommandKind) + { + case GitCommandKind.StatusPorcelainBranch: + return (["status", "--porcelain=2", "--branch"], "git status --porcelain=2 --branch"); + case GitCommandKind.StatusShortBranch: + return (["status", "--short", "--branch"], "git status --short --branch"); + case GitCommandKind.StatusShort: + return (["status", "--short"], "git status --short"); + case GitCommandKind.ShowTopLevel: + return (["rev-parse", "--show-toplevel"], "git rev-parse --show-toplevel"); + case GitCommandKind.PullRebase: + return (["pull", "--rebase"], "git pull --rebase"); + case GitCommandKind.CreateBranch: + if (string.IsNullOrWhiteSpace(request.BranchName)) + throw new ArgumentException("BranchName is required for CreateBranch.", nameof(request)); + return (["switch", "-c", request.BranchName!], $"git switch -c {request.BranchName}"); + case GitCommandKind.SetUpstream: + if (string.IsNullOrWhiteSpace(request.BranchName)) + throw new ArgumentException("BranchName is required for SetUpstream.", nameof(request)); + var remoteName = string.IsNullOrWhiteSpace(request.RemoteName) ? "origin" : request.RemoteName!; + return ( + ["push", "--set-upstream", remoteName, request.BranchName!], + $"git push --set-upstream {remoteName} {request.BranchName}"); + case GitCommandKind.GetRemoteUrl: + var remoteToInspect = string.IsNullOrWhiteSpace(request.RemoteName) ? "origin" : request.RemoteName!; + return ( + ["remote", "get-url", remoteToInspect], + $"git remote get-url {remoteToInspect}"); + default: + throw new ArgumentOutOfRangeException(nameof(request), request.CommandKind, "Unsupported git command kind."); + } + } +} diff --git a/PowerForge/Services/GitHubActionsCacheCleanupService.cs b/PowerForge/Services/GitHubActionsCacheCleanupService.cs new file mode 100644 index 00000000..3c04cdad --- /dev/null +++ b/PowerForge/Services/GitHubActionsCacheCleanupService.cs @@ -0,0 +1,561 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace PowerForge; + +/// +/// GitHub Actions cache cleanup service used to reclaim repository cache quota. +/// +public sealed class GitHubActionsCacheCleanupService +{ + private readonly ILogger _logger; + private readonly HttpClient _client; + private static readonly HttpClient SharedClient = CreateSharedClient(); + + /// + /// Creates a cache cleanup service with a logger and optional custom HTTP client. + /// + /// Logger used for progress and diagnostics. + /// Optional HTTP client (for tests/custom transports). + public GitHubActionsCacheCleanupService(ILogger logger, HttpClient? client = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _client = client ?? SharedClient; + } + + /// + /// Executes a cleanup run against GitHub Actions caches. + /// + /// Cleanup specification. + /// Run summary with planned/deleted items and counters. + public GitHubActionsCacheCleanupResult Prune(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 now = DateTimeOffset.UtcNow; + var ageCutoff = normalized.MaxAgeDays is > 0 + ? now.AddDays(-normalized.MaxAgeDays.Value) + : (DateTimeOffset?)null; + + var matched = allCaches + .Where(c => MatchesAnyPattern(c.Key, normalized.IncludeKeys, defaultWhenEmpty: true)) + .Where(c => !MatchesAnyPattern(c.Key, normalized.ExcludeKeys, defaultWhenEmpty: false)) + .ToArray(); + + var planned = new List(); + var keptByRecentWindow = 0; + var keptByAgeThreshold = 0; + + foreach (var byKey in matched.GroupBy(c => c.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + { + var ordered = byKey + .OrderByDescending(GetSortTimestamp) + .ThenByDescending(c => c.Id) + .ToArray(); + + for (var index = 0; index < ordered.Length; index++) + { + var cache = ordered[index]; + if (index < normalized.KeepLatestPerKey) + { + keptByRecentWindow++; + continue; + } + + if (ageCutoff is not null && GetSortTimestamp(cache) > ageCutoff.Value) + { + keptByAgeThreshold++; + continue; + } + + planned.Add(ToItem(cache, BuildSelectionReason(normalized, ageCutoff))); + } + } + + var orderedPlanned = planned + .OrderBy(c => c.LastAccessedAt ?? c.CreatedAt ?? DateTimeOffset.MinValue) + .ThenBy(c => c.Key, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.Id) + .Take(normalized.MaxDelete) + .ToArray(); + + var result = new GitHubActionsCacheCleanupResult + { + Repository = normalized.Repository, + IncludeKeys = normalized.IncludeKeys, + ExcludeKeys = normalized.ExcludeKeys, + KeepLatestPerKey = normalized.KeepLatestPerKey, + MaxAgeDays = normalized.MaxAgeDays is > 0 ? normalized.MaxAgeDays : null, + MaxDelete = normalized.MaxDelete, + DryRun = normalized.DryRun, + UsageBefore = usageBefore, + ScannedCaches = allCaches.Length, + MatchedCaches = matched.Length, + KeptByRecentWindow = keptByRecentWindow, + KeptByAgeThreshold = keptByAgeThreshold, + Planned = orderedPlanned, + PlannedDeletes = orderedPlanned.Length, + PlannedDeleteBytes = orderedPlanned.Sum(c => c.SizeInBytes), + Success = true + }; + + _logger.Info($"GitHub caches scanned: {result.ScannedCaches}, matched: {result.MatchedCaches}, planned: {result.PlannedDeletes}."); + + if (normalized.DryRun || orderedPlanned.Length == 0) + { + if (normalized.DryRun) + _logger.Info("GitHub cache cleanup dry-run enabled. No delete requests were sent."); + return result; + } + + var deleted = new List(); + var failed = new List(); + + foreach (var item in orderedPlanned) + { + var deleteResult = DeleteCache(normalized.ApiBaseUri, normalized.Repository, normalized.Token, item.Id); + if (deleteResult.Ok) + { + deleted.Add(item); + _logger.Verbose($"Deleted cache #{item.Id} ({item.Key})."); + continue; + } + + item.DeleteStatusCode = deleteResult.StatusCode; + item.DeleteError = deleteResult.Error; + failed.Add(item); + _logger.Warn($"Failed to delete cache #{item.Id} ({item.Key}): {deleteResult.Error}"); + } + + result.Deleted = deleted.ToArray(); + result.Failed = failed.ToArray(); + 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.Success = failed.Count == 0 || !normalized.FailOnDeleteError; + + if (!result.Success) + result.Message = "One or more cache delete operations failed."; + else if (failed.Count > 0) + result.Message = "Cleanup finished with non-fatal delete errors."; + + return result; + } + + private sealed class GitHubActionsCacheRecord + { + public long Id { get; set; } + public string Key { get; set; } = string.Empty; + public string? Ref { get; set; } + public string? Version { get; set; } + public long SizeInBytes { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public DateTimeOffset? LastAccessedAt { get; set; } + } + + private sealed class NormalizedSpec + { + public Uri ApiBaseUri { get; set; } = new Uri("https://api.github.com/", UriKind.Absolute); + public string Repository { get; set; } = string.Empty; + public string Token { get; set; } = string.Empty; + public string[] IncludeKeys { get; set; } = Array.Empty(); + public string[] ExcludeKeys { get; set; } = Array.Empty(); + public int KeepLatestPerKey { get; set; } + public int? MaxAgeDays { get; set; } + public int MaxDelete { get; set; } + public int PageSize { get; set; } + public bool DryRun { get; set; } + public bool FailOnDeleteError { get; set; } + } + + private NormalizedSpec NormalizeSpec(GitHubActionsCacheCleanupSpec spec) + { + var repository = NormalizeRepository(spec.Repository); + if (string.IsNullOrWhiteSpace(repository)) + throw new InvalidOperationException("Repository is required (owner/repo)."); + + var token = (spec.Token ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("GitHub token is required."); + + return new NormalizedSpec + { + ApiBaseUri = NormalizeApiBaseUri(spec.ApiBaseUrl), + Repository = repository, + Token = token, + IncludeKeys = NormalizePatterns(spec.IncludeKeys), + ExcludeKeys = NormalizePatterns(spec.ExcludeKeys), + KeepLatestPerKey = Math.Max(0, spec.KeepLatestPerKey), + MaxAgeDays = spec.MaxAgeDays is < 1 ? null : spec.MaxAgeDays, + MaxDelete = Math.Max(1, spec.MaxDelete), + PageSize = Clamp(spec.PageSize, 1, 100), + DryRun = spec.DryRun, + FailOnDeleteError = spec.FailOnDeleteError + }; + } + + private GitHubActionsCacheUsage? TryGetUsage(Uri apiBaseUri, string repository, string token) + { + try + { + var uri = BuildApiUri(apiBaseUri, repository, "actions/cache/usage"); + 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(); + if (!response.IsSuccessStatusCode) + { + _logger.Verbose($"GitHub cache usage lookup failed: HTTP {(int)response.StatusCode}."); + return null; + } + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + return new GitHubActionsCacheUsage + { + ActiveCachesCount = TryGetInt32(root, "active_caches_count"), + ActiveCachesSizeInBytes = TryGetInt64(root, "active_caches_size_in_bytes") + }; + } + catch (Exception ex) + { + _logger.Verbose($"GitHub cache usage lookup failed: {ex.Message}"); + return null; + } + } + + private GitHubActionsCacheRecord[] ListCaches(Uri apiBaseUri, string repository, string token, int pageSize) + { + var records = new List(); + var page = 1; + int? totalCount = null; + + while (true) + { + var uri = BuildApiUri(apiBaseUri, repository, $"actions/caches?per_page={pageSize}&page={page}"); + 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(); + if (!response.IsSuccessStatusCode) + throw BuildHttpFailure("listing caches", response, body); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (root.TryGetProperty("total_count", out var totalElement) && totalElement.ValueKind == JsonValueKind.Number) + totalCount = totalElement.GetInt32(); + + if (!root.TryGetProperty("actions_caches", out var itemsElement) || itemsElement.ValueKind != JsonValueKind.Array) + break; + + var pageItems = itemsElement.EnumerateArray().Select(ParseCache).ToArray(); + if (pageItems.Length == 0) + break; + + records.AddRange(pageItems); + + if (pageItems.Length < pageSize) + break; + + if (totalCount.HasValue && records.Count >= totalCount.Value) + break; + + page++; + } + + return records.ToArray(); + } + + private (bool Ok, int? StatusCode, string? Error) DeleteCache(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(); + if (response.IsSuccessStatusCode) + return (true, (int)response.StatusCode, null); + + var body = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + var error = BuildHttpFailure("deleting cache", response, body).Message; + return (false, (int)response.StatusCode, error); + } + + private static DateTimeOffset GetSortTimestamp(GitHubActionsCacheRecord record) + => record.LastAccessedAt ?? record.CreatedAt ?? DateTimeOffset.MinValue; + + private static GitHubActionsCacheCleanupItem ToItem(GitHubActionsCacheRecord record, string? reason) + { + return new GitHubActionsCacheCleanupItem + { + Id = record.Id, + Key = record.Key, + Ref = record.Ref, + Version = record.Version, + SizeInBytes = record.SizeInBytes, + CreatedAt = record.CreatedAt, + LastAccessedAt = record.LastAccessedAt, + Reason = reason + }; + } + + private static string BuildSelectionReason(NormalizedSpec spec, DateTimeOffset? ageCutoff) + { + if (ageCutoff is null) + return $"older-than-keep-window:{spec.KeepLatestPerKey}"; + + return $"older-than-keep-window:{spec.KeepLatestPerKey};older-than-days:{spec.MaxAgeDays}"; + } + + private static string[] NormalizePatterns(string[]? patterns) + { + return (patterns ?? Array.Empty()) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool MatchesAnyPattern(string value, string[] patterns, bool defaultWhenEmpty) + { + if (patterns is null || patterns.Length == 0) + return defaultWhenEmpty; + + foreach (var pattern in patterns) + { + if (MatchSinglePattern(value, pattern)) + return true; + } + + return false; + } + + private static bool MatchSinglePattern(string value, string pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) + return false; + + var trimmed = pattern.Trim(); + if (trimmed.StartsWith("re:", StringComparison.OrdinalIgnoreCase)) + { + var expression = trimmed.Substring(3); + if (string.IsNullOrWhiteSpace(expression)) + return false; + + return Regex.IsMatch(value, expression, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + if (!trimmed.Contains('*') && !trimmed.Contains('?')) + return string.Equals(value, trimmed, StringComparison.OrdinalIgnoreCase); + + var regexPattern = "^" + Regex.Escape(trimmed) + .Replace("\\*", ".*") + .Replace("\\?", ".") + "$"; + + return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private static GitHubActionsCacheRecord ParseCache(JsonElement cache) + { + return new GitHubActionsCacheRecord + { + Id = TryGetInt64(cache, "id"), + Key = TryGetString(cache, "key") ?? string.Empty, + Ref = TryGetString(cache, "ref"), + Version = TryGetString(cache, "version"), + SizeInBytes = TryGetInt64(cache, "size_in_bytes"), + CreatedAt = TryGetDateTimeOffset(cache, "created_at"), + LastAccessedAt = TryGetDateTimeOffset(cache, "last_accessed_at") + }; + } + + private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out value)) + return true; + + value = default; + return false; + } + + private static string? TryGetString(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + return null; + + return value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } + + private static long TryGetInt64(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + return 0; + + if (value.ValueKind != JsonValueKind.Number) + return 0; + + return value.TryGetInt64(out var parsed) ? parsed : 0; + } + + private static int TryGetInt32(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + return 0; + + if (value.ValueKind != JsonValueKind.Number) + return 0; + + return value.TryGetInt32(out var parsed) ? parsed : 0; + } + + private static DateTimeOffset? TryGetDateTimeOffset(JsonElement element, string propertyName) + { + var text = TryGetString(element, propertyName); + if (string.IsNullOrWhiteSpace(text)) + return null; + + return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed) + ? parsed + : (DateTimeOffset?)null; + } + + private static Exception BuildHttpFailure(string operation, HttpResponseMessage response, string? body) + { + var status = (int)response.StatusCode; + var reason = response.ReasonPhrase ?? "HTTP error"; + var trimmedBody = TrimForMessage(body); + var rateLimitHint = BuildRateLimitHint(response); + var details = string.IsNullOrWhiteSpace(trimmedBody) ? reason : $"{reason}. {trimmedBody}"; + if (!string.IsNullOrWhiteSpace(rateLimitHint)) + details = $"{details} {rateLimitHint}"; + + return new InvalidOperationException($"GitHub API error while {operation} (HTTP {status}). {details}".Trim()); + } + + private static string? BuildRateLimitHint(HttpResponseMessage response) + { + if (response.StatusCode != HttpStatusCode.Forbidden) + return null; + + if (!response.Headers.TryGetValues("X-RateLimit-Remaining", out var remainingValues)) + return null; + + var remaining = (remainingValues ?? Array.Empty()).FirstOrDefault(); + if (!string.Equals(remaining, "0", StringComparison.OrdinalIgnoreCase)) + return null; + + var resetText = string.Empty; + if (response.Headers.TryGetValues("X-RateLimit-Reset", out var resetValues)) + { + var raw = (resetValues ?? Array.Empty()).FirstOrDefault(); + if (long.TryParse(raw, out var epoch)) + { + var resetUtc = DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime; + resetText = $"Rate limit resets at {resetUtc:O} UTC."; + } + } + + return string.IsNullOrWhiteSpace(resetText) + ? "GitHub API rate limit exceeded." + : $"GitHub API rate limit exceeded. {resetText}"; + } + + private static string TrimForMessage(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + var normalized = text!.Trim(); + const int maxLength = 2000; + if (normalized.Length <= maxLength) + return normalized; + + return normalized.Substring(0, maxLength) + "..."; + } + + private static string NormalizeRepository(string? repository) + { + if (string.IsNullOrWhiteSpace(repository)) + return string.Empty; + + var normalized = repository!.Trim().Trim('"').Trim(); + normalized = normalized.Trim('/'); + return normalized; + } + + private static Uri NormalizeApiBaseUri(string? apiBaseUrl) + { + var raw = (apiBaseUrl ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return new Uri("https://api.github.com/", UriKind.Absolute); + + if (!raw.EndsWith("/", StringComparison.Ordinal)) + raw += "/"; + + if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) + throw new InvalidOperationException($"Invalid GitHub API base URL: {apiBaseUrl}"); + + if (!uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Unsupported GitHub API base URL scheme: {uri.Scheme}"); + + return uri; + } + + private static Uri BuildApiUri(Uri apiBaseUri, string repository, string relativePathWithQuery) + { + var repoPath = repository.Trim().Trim('/'); + var relative = $"repos/{repoPath}/{relativePathWithQuery.TrimStart('/')}"; + return new Uri(apiBaseUri, relative); + } + + private static int Clamp(int value, int min, int max) + { + if (value < min) return min; + if (value > max) return max; + return value; + } + + private static HttpClient CreateSharedClient() + { + HttpMessageHandler handler; +#if NETFRAMEWORK + handler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }; +#else + handler = new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + MaxConnectionsPerServer = 16 + }; +#endif + + var client = new HttpClient(handler, disposeHandler: true) + { + Timeout = TimeSpan.FromMinutes(5) + }; + client.DefaultRequestHeaders.UserAgent.Clear(); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PowerForge", "1.0")); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + return client; + } +} diff --git a/PowerForge/Services/GitHubHousekeepingService.cs b/PowerForge/Services/GitHubHousekeepingService.cs new file mode 100644 index 00000000..9aa3ad5f --- /dev/null +++ b/PowerForge/Services/GitHubHousekeepingService.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PowerForge; + +/// +/// Orchestrates repository cache/artifact cleanup and optional runner housekeeping from one spec. +/// +public sealed class GitHubHousekeepingService +{ + private readonly ILogger _logger; + private readonly GitHubArtifactCleanupService _artifactService; + private readonly GitHubActionsCacheCleanupService _cacheService; + private readonly RunnerHousekeepingService _runnerService; + + /// + /// Creates a housekeeping orchestrator. + /// + public GitHubHousekeepingService( + ILogger logger, + GitHubArtifactCleanupService? artifactService = null, + GitHubActionsCacheCleanupService? cacheService = null, + RunnerHousekeepingService? runnerService = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _artifactService = artifactService ?? new GitHubArtifactCleanupService(logger); + _cacheService = cacheService ?? new GitHubActionsCacheCleanupService(logger); + _runnerService = runnerService ?? new RunnerHousekeepingService(logger); + } + + /// + /// Executes the configured housekeeping sections. + /// + public GitHubHousekeepingResult Run(GitHubHousekeepingSpec spec) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var requested = new List(); + var completed = new List(); + var failed = new List(); + static string? NormalizeNullable(string? value) + { + var text = value ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + return null; + + return text.Trim(); + } + + static string NormalizeRequired(string? value) + { + var text = value ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + return text.Trim(); + } + + if (spec.Artifacts.Enabled) requested.Add("artifacts"); + if (spec.Caches.Enabled) requested.Add("caches"); + if (spec.Runner.Enabled) requested.Add("runner"); + + var repositoryValue = spec.Repository; + var tokenValue = spec.Token; + + var result = new GitHubHousekeepingResult + { + Repository = NormalizeNullable(repositoryValue), + DryRun = spec.DryRun, + RequestedSections = requested.ToArray() + }; + + if (requested.Count == 0) + { + result.Message = "No housekeeping sections are enabled."; + return result; + } + + var requiresRemoteIdentity = spec.Artifacts.Enabled || spec.Caches.Enabled; + var repository = NormalizeRequired(repositoryValue); + var token = NormalizeRequired(tokenValue); + + if (requiresRemoteIdentity) + { + if (string.IsNullOrWhiteSpace(repository)) + { + failed.Add("artifacts"); + if (spec.Caches.Enabled) + failed.Add("caches"); + result.Success = false; + result.Message = "Repository is required for GitHub artifact/cache cleanup."; + } + + if (string.IsNullOrWhiteSpace(token)) + { + if (spec.Artifacts.Enabled && !failed.Contains("artifacts", StringComparer.OrdinalIgnoreCase)) + failed.Add("artifacts"); + if (spec.Caches.Enabled && !failed.Contains("caches", StringComparer.OrdinalIgnoreCase)) + failed.Add("caches"); + result.Success = false; + result.Message = string.IsNullOrWhiteSpace(result.Message) + ? "GitHub token is required for GitHub artifact/cache cleanup." + : result.Message + " GitHub token is required for GitHub artifact/cache cleanup."; + } + } + + if (spec.Artifacts.Enabled && !failed.Contains("artifacts", StringComparer.OrdinalIgnoreCase)) + { + var artifactsResult = _artifactService.Prune(new GitHubArtifactCleanupSpec + { + ApiBaseUrl = spec.ApiBaseUrl, + Repository = repository, + Token = token, + IncludeNames = spec.Artifacts.IncludeNames ?? Array.Empty(), + ExcludeNames = spec.Artifacts.ExcludeNames ?? Array.Empty(), + KeepLatestPerName = spec.Artifacts.KeepLatestPerName, + MaxAgeDays = spec.Artifacts.MaxAgeDays, + MaxDelete = spec.Artifacts.MaxDelete, + PageSize = spec.Artifacts.PageSize, + DryRun = spec.DryRun, + FailOnDeleteError = spec.Artifacts.FailOnDeleteError + }); + + result.Artifacts = artifactsResult; + if (artifactsResult.Success) completed.Add("artifacts"); else failed.Add("artifacts"); + } + + if (spec.Caches.Enabled && !failed.Contains("caches", StringComparer.OrdinalIgnoreCase)) + { + var cachesResult = _cacheService.Prune(new GitHubActionsCacheCleanupSpec + { + ApiBaseUrl = spec.ApiBaseUrl, + Repository = repository, + Token = token, + IncludeKeys = spec.Caches.IncludeKeys ?? Array.Empty(), + ExcludeKeys = spec.Caches.ExcludeKeys ?? Array.Empty(), + KeepLatestPerKey = spec.Caches.KeepLatestPerKey, + MaxAgeDays = spec.Caches.MaxAgeDays, + MaxDelete = spec.Caches.MaxDelete, + PageSize = spec.Caches.PageSize, + DryRun = spec.DryRun, + FailOnDeleteError = spec.Caches.FailOnDeleteError + }); + + result.Caches = cachesResult; + if (cachesResult.Success) completed.Add("caches"); else failed.Add("caches"); + } + + if (spec.Runner.Enabled) + { + var runnerResult = _runnerService.Clean(new RunnerHousekeepingSpec + { + RunnerTempPath = spec.Runner.RunnerTempPath, + WorkRootPath = spec.Runner.WorkRootPath, + RunnerRootPath = spec.Runner.RunnerRootPath, + DiagnosticsRootPath = spec.Runner.DiagnosticsRootPath, + ToolCachePath = spec.Runner.ToolCachePath, + MinFreeGb = spec.Runner.MinFreeGb, + AggressiveThresholdGb = spec.Runner.AggressiveThresholdGb, + DiagnosticsRetentionDays = spec.Runner.DiagnosticsRetentionDays, + ActionsRetentionDays = spec.Runner.ActionsRetentionDays, + ToolCacheRetentionDays = spec.Runner.ToolCacheRetentionDays, + DryRun = spec.DryRun, + Aggressive = spec.Runner.Aggressive, + CleanDiagnostics = spec.Runner.CleanDiagnostics, + CleanRunnerTemp = spec.Runner.CleanRunnerTemp, + CleanActionsCache = spec.Runner.CleanActionsCache, + CleanToolCache = spec.Runner.CleanToolCache, + ClearDotNetCaches = spec.Runner.ClearDotNetCaches, + PruneDocker = spec.Runner.PruneDocker, + IncludeDockerVolumes = spec.Runner.IncludeDockerVolumes, + AllowSudo = spec.Runner.AllowSudo + }); + + result.Runner = runnerResult; + if (runnerResult.Success) completed.Add("runner"); else failed.Add("runner"); + } + + result.CompletedSections = completed.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + result.FailedSections = failed.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + result.Success = result.FailedSections.Length == 0; + + if (!result.Success && string.IsNullOrWhiteSpace(result.Message)) + result.Message = $"Housekeeping failed for section(s): {string.Join(", ", result.FailedSections)}."; + + _logger.Info($"GitHub housekeeping requested: {string.Join(", ", result.RequestedSections)}."); + _logger.Info($"GitHub housekeeping completed: {string.Join(", ", result.CompletedSections)}."); + if (result.FailedSections.Length > 0) + _logger.Warn($"GitHub housekeeping failed: {string.Join(", ", result.FailedSections)}."); + + return result; + } +} diff --git a/PowerForge/Services/ModuleBuildHostService.cs b/PowerForge/Services/ModuleBuildHostService.cs new file mode 100644 index 00000000..2457d9d8 --- /dev/null +++ b/PowerForge/Services/ModuleBuildHostService.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; +using System.Text; + +namespace PowerForge; + +/// +/// Shared host service for invoking repository Build-Module.ps1 scripts. +/// +public sealed class ModuleBuildHostService +{ + private readonly IPowerShellRunner _powerShellRunner; + + /// + /// Creates a new host service using the default PowerShell runner. + /// + public ModuleBuildHostService() + : this(new PowerShellRunner()) + { + } + + internal ModuleBuildHostService(IPowerShellRunner powerShellRunner) + { + _powerShellRunner = powerShellRunner ?? throw new ArgumentNullException(nameof(powerShellRunner)); + } + + /// + /// Exports pipeline JSON from a module build script. + /// + public Task ExportPipelineJsonAsync(ModuleBuildHostExportRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ValidateRequiredPath(request.RepositoryRoot, nameof(request.RepositoryRoot)); + ValidateRequiredPath(request.ScriptPath, nameof(request.ScriptPath)); + ValidateRequiredPath(request.ModulePath, nameof(request.ModulePath)); + ValidateRequiredPath(request.OutputPath, nameof(request.OutputPath)); + + var script = BuildExportScript(request.RepositoryRoot, request.ScriptPath, request.OutputPath, request.ModulePath); + return RunCommandAsync(request.RepositoryRoot, script, cancellationToken); + } + + /// + /// Executes a module build script while disabling signing-specific configuration overrides. + /// + public Task ExecuteBuildAsync(ModuleBuildHostBuildRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ValidateRequiredPath(request.RepositoryRoot, nameof(request.RepositoryRoot)); + ValidateRequiredPath(request.ScriptPath, nameof(request.ScriptPath)); + ValidateRequiredPath(request.ModulePath, nameof(request.ModulePath)); + + var script = BuildBuildScript(request.RepositoryRoot, request.ScriptPath, request.ModulePath); + return RunCommandAsync(request.RepositoryRoot, script, cancellationToken); + } + + private async Task RunCommandAsync(string workingDirectory, string script, CancellationToken cancellationToken) + { + var startedAt = Stopwatch.StartNew(); + var result = await Task.Run(() => _powerShellRunner.Run(PowerShellRunRequest.ForCommand( + commandText: script, + timeout: TimeSpan.FromMinutes(15), + preferPwsh: !OperatingSystem.IsWindows(), + workingDirectory: workingDirectory, + executableOverride: Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"))), cancellationToken).ConfigureAwait(false); + startedAt.Stop(); + + return new ModuleBuildHostExecutionResult { + ExitCode = result.ExitCode, + Duration = startedAt.Elapsed, + StandardOutput = result.StdOut, + StandardError = result.StdErr, + Executable = result.Executable + }; + } + + private static string BuildExportScript(string repositoryRoot, string scriptPath, string outputPath, string modulePath) + { + var moduleRoot = Directory.GetParent(Path.GetDirectoryName(scriptPath)!)?.FullName ?? repositoryRoot; + return string.Join(Environment.NewLine, new[] { + "$ErrorActionPreference = 'Stop'", + $"Set-Location -LiteralPath {QuoteLiteral(moduleRoot)}", + BuildModuleImportClause(modulePath), + $"$targetJson = {QuoteLiteral(outputPath)}", + "function Invoke-ModuleBuild {", + " [CmdletBinding(PositionalBinding = $false)]", + " param(", + " [Parameter(Position = 0)][string]$ModuleName,", + " [Parameter(Position = 1)][scriptblock]$Settings,", + " [string]$Path,", + " [switch]$ExitCode,", + " [Parameter(ValueFromRemainingArguments = $true)][object[]]$RemainingArgs", + " )", + " if (-not $Settings -and $RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -is [scriptblock]) {", + " $Settings = [scriptblock]$RemainingArgs[0]", + " if ($RemainingArgs.Count -gt 1) {", + " $RemainingArgs = $RemainingArgs[1..($RemainingArgs.Count - 1)]", + " } else {", + " $RemainingArgs = @()", + " }", + " }", + " $cmd = Get-Command -Name Invoke-ModuleBuild -CommandType Cmdlet -Module PSPublishModule", + " $invokeArgs = @{ ModuleName = $ModuleName; JsonOnly = $true; JsonPath = $targetJson; NoInteractive = $true }", + " if ($null -ne $Settings) { $invokeArgs.Settings = $Settings }", + " if (-not [string]::IsNullOrWhiteSpace($Path)) { $invokeArgs.Path = $Path }", + " if ($ExitCode) { $invokeArgs.ExitCode = $true }", + " if ($RemainingArgs.Count -gt 0) {", + " & $cmd @invokeArgs @RemainingArgs", + " } else {", + " & $cmd @invokeArgs", + " }", + "}", + "function Build-Module {", + " [CmdletBinding(PositionalBinding = $false)]", + " param(", + " [Parameter(Position = 0)][string]$ModuleName,", + " [Parameter(Position = 1)][scriptblock]$Settings,", + " [string]$Path,", + " [switch]$ExitCode,", + " [Parameter(ValueFromRemainingArguments = $true)][object[]]$RemainingArgs", + " )", + " if (-not $Settings -and $RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -is [scriptblock]) {", + " $Settings = [scriptblock]$RemainingArgs[0]", + " if ($RemainingArgs.Count -gt 1) {", + " $RemainingArgs = $RemainingArgs[1..($RemainingArgs.Count - 1)]", + " } else {", + " $RemainingArgs = @()", + " }", + " }", + " $forwardArgs = @{ ModuleName = $ModuleName }", + " if ($null -ne $Settings) { $forwardArgs.Settings = $Settings }", + " if (-not [string]::IsNullOrWhiteSpace($Path)) { $forwardArgs.Path = $Path }", + " if ($ExitCode) { $forwardArgs.ExitCode = $true }", + " if ($RemainingArgs.Count -gt 0) {", + " Invoke-ModuleBuild @forwardArgs @RemainingArgs", + " } else {", + " Invoke-ModuleBuild @forwardArgs", + " }", + "}", + "Set-Alias -Name Invoke-ModuleBuilder -Value Invoke-ModuleBuild -Scope Local", + $". {QuoteLiteral(scriptPath)}" + }); + } + + private static string BuildBuildScript(string repositoryRoot, string scriptPath, string modulePath) + { + var moduleRoot = Directory.GetParent(Path.GetDirectoryName(scriptPath)!)?.FullName ?? repositoryRoot; + return string.Join(Environment.NewLine, new[] { + "$ErrorActionPreference = 'Stop'", + $"Set-Location -LiteralPath {QuoteLiteral(moduleRoot)}", + BuildModuleImportClause(modulePath), + "function New-ConfigurationBuild {", + " param([Parameter(ValueFromRemainingArguments = $true)][object[]]$RemainingArgs)", + " $cmd = Get-Command -Name New-ConfigurationBuild -Module PSPublishModule", + " if ($RemainingArgs.Count -eq 1 -and $RemainingArgs[0] -is [System.Collections.IDictionary]) {", + " $params = @{}", + " foreach ($key in $RemainingArgs[0].Keys) { $params[$key] = $RemainingArgs[0][$key] }", + " $params['SignModule'] = $false", + " $params['CertificateThumbprint'] = $null", + " & $cmd @params", + " return", + " }", + " & $cmd @RemainingArgs -SignModule:$false", + "}", + $". {QuoteLiteral(scriptPath)}" + }); + } + + private static string BuildModuleImportClause(string modulePath) + => File.Exists(modulePath) + ? $"try {{ Import-Module {QuoteLiteral(modulePath)} -Force -ErrorAction Stop }} catch {{ Import-Module PSPublishModule -Force -ErrorAction Stop }}" + : "Import-Module PSPublishModule -Force -ErrorAction Stop"; + + private static string QuoteLiteral(string value) + => $"'{(value ?? string.Empty).Replace("'", "''")}'"; + + private static void ValidateRequiredPath(string value, string argumentName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException($"{argumentName} is required.", argumentName); + } +} diff --git a/PowerForge/Services/ModuleManifestMetadataReader.cs b/PowerForge/Services/ModuleManifestMetadataReader.cs new file mode 100644 index 00000000..dd5f25e5 --- /dev/null +++ b/PowerForge/Services/ModuleManifestMetadataReader.cs @@ -0,0 +1,47 @@ +using System.Text.RegularExpressions; + +namespace PowerForge; + +/// +/// Reads minimal metadata from a PowerShell module manifest using shared C# parsing helpers. +/// +public sealed class ModuleManifestMetadataReader +{ + private static readonly Regex RootModuleRegex = new(@"(?im)^\s*RootModule\s*=\s*['""](?[^'""]+)['""]", RegexOptions.Compiled); + private static readonly Regex ModuleVersionRegex = new(@"(?im)^\s*ModuleVersion\s*=\s*['""](?[^'""]+)['""]", RegexOptions.Compiled); + private static readonly Regex PreReleaseRegex = new(@"(?im)^\s*Prerelease\s*=\s*['""](?[^'""]+)['""]", RegexOptions.Compiled); + + /// + /// Reads module name, version, and prerelease metadata from the specified manifest. + /// + /// Path to the module manifest. + /// Resolved module manifest metadata. + public ModuleManifestMetadata Read(string manifestPath) + { + if (string.IsNullOrWhiteSpace(manifestPath)) + throw new ArgumentException("Manifest path is required.", nameof(manifestPath)); + + var fullPath = Path.GetFullPath(manifestPath.Trim().Trim('"')); + if (!File.Exists(fullPath)) + throw new FileNotFoundException($"Manifest file was not found: {fullPath}", fullPath); + + var moduleName = Path.GetFileNameWithoutExtension(fullPath) ?? string.Empty; + var moduleVersion = "0.0.0"; + string? preRelease = null; + var content = File.ReadAllText(fullPath); + + var rootModuleMatch = RootModuleRegex.Match(content); + if (rootModuleMatch.Success) + moduleName = Path.GetFileNameWithoutExtension(rootModuleMatch.Groups["value"].Value); + + var versionMatch = ModuleVersionRegex.Match(content); + if (versionMatch.Success) + moduleVersion = versionMatch.Groups["value"].Value; + + var preReleaseMatch = PreReleaseRegex.Match(content); + if (preReleaseMatch.Success) + preRelease = preReleaseMatch.Groups["value"].Value; + + return new ModuleManifestMetadata(moduleName, moduleVersion, preRelease); + } +} diff --git a/PowerForge/Services/ModulePublishConfigurationReader.cs b/PowerForge/Services/ModulePublishConfigurationReader.cs new file mode 100644 index 00000000..946eff7f --- /dev/null +++ b/PowerForge/Services/ModulePublishConfigurationReader.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PowerForge; + +/// +/// Reads module publish configuration segments from a PowerForge module pipeline JSON document. +/// +public sealed class ModulePublishConfigurationReader +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + /// + /// Reads publish configuration segments from the specified module pipeline JSON file. + /// + /// Path to a module pipeline JSON file. + /// Publish configurations declared by the pipeline. + public IReadOnlyList Read(string jsonPath) + { + if (string.IsNullOrWhiteSpace(jsonPath)) + throw new ArgumentException("JSON path is required.", nameof(jsonPath)); + + var fullPath = Path.GetFullPath(jsonPath.Trim().Trim('"')); + if (!File.Exists(fullPath)) + throw new FileNotFoundException($"Module pipeline JSON file was not found: {fullPath}", fullPath); + + return ReadFromJson(File.ReadAllText(fullPath)); + } + + /// + /// Reads publish configuration segments from a module pipeline JSON payload. + /// + /// Module pipeline JSON text. + /// Publish configurations declared by the pipeline. + public IReadOnlyList ReadFromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return Array.Empty(); + + var spec = JsonSerializer.Deserialize(json, JsonOptions); + if (spec?.Segments is not { Length: > 0 }) + return Array.Empty(); + + return spec.Segments + .OfType() + .Select(segment => ClonePublishConfiguration(segment.Configuration)) + .ToArray(); + } + + private static PublishConfiguration ClonePublishConfiguration(PublishConfiguration configuration) + { + configuration ??= new PublishConfiguration(); + return new PublishConfiguration { + Destination = configuration.Destination, + Tool = configuration.Tool, + ApiKey = configuration.ApiKey, + ID = configuration.ID, + Enabled = configuration.Enabled, + UserName = configuration.UserName, + RepositoryName = configuration.RepositoryName, + Repository = CloneRepository(configuration.Repository), + Force = configuration.Force, + OverwriteTagName = configuration.OverwriteTagName, + DoNotMarkAsPreRelease = configuration.DoNotMarkAsPreRelease, + GenerateReleaseNotes = configuration.GenerateReleaseNotes, + Verbose = configuration.Verbose + }; + } + + private static PublishRepositoryConfiguration? CloneRepository(PublishRepositoryConfiguration? repository) + { + if (repository is null) + return null; + + return new PublishRepositoryConfiguration { + Name = repository.Name, + Uri = repository.Uri, + SourceUri = repository.SourceUri, + PublishUri = repository.PublishUri, + Trusted = repository.Trusted, + Priority = repository.Priority, + ApiVersion = repository.ApiVersion, + EnsureRegistered = repository.EnsureRegistered, + UnregisterAfterUse = repository.UnregisterAfterUse, + Credential = repository.Credential is null + ? null + : new RepositoryCredential { + UserName = repository.Credential.UserName, + Secret = repository.Credential.Secret + } + }; + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions { + PropertyNameCaseInsensitive = true + }; + options.Converters.Add(new JsonStringEnumConverter()); + options.Converters.Add(new ConfigurationSegmentJsonConverter()); + return options; + } +} diff --git a/PowerForge/Services/ModulePublishTagBuilder.cs b/PowerForge/Services/ModulePublishTagBuilder.cs new file mode 100644 index 00000000..54665396 --- /dev/null +++ b/PowerForge/Services/ModulePublishTagBuilder.cs @@ -0,0 +1,37 @@ +namespace PowerForge; + +/// +/// Builds GitHub release tags for module publish operations. +/// +public sealed class ModulePublishTagBuilder +{ + /// + /// Builds the publish tag for the provided module publish configuration. + /// + /// Publish configuration. + /// Module name. + /// Resolved stable version. + /// Optional prerelease label. + /// Resolved tag name. + public string BuildTag(PublishConfiguration publish, string moduleName, string resolvedVersion, string? preRelease) + { + ArgumentNullException.ThrowIfNull(publish); + + var versionWithPreRelease = string.IsNullOrWhiteSpace(preRelease) + ? resolvedVersion + : $"{resolvedVersion}-{preRelease}"; + + if (string.IsNullOrWhiteSpace(publish.OverwriteTagName)) + return $"v{versionWithPreRelease}"; + + return publish.OverwriteTagName! + .Replace("", moduleName) + .Replace("{ModuleName}", moduleName) + .Replace("", resolvedVersion) + .Replace("{ModuleVersion}", resolvedVersion) + .Replace("", versionWithPreRelease) + .Replace("{ModuleVersionWithPreRelease}", versionWithPreRelease) + .Replace("", $"v{versionWithPreRelease}") + .Replace("{TagModuleVersionWithPreRelease}", $"v{versionWithPreRelease}"); + } +} diff --git a/PowerForge/Services/NuGetPackagePublishService.cs b/PowerForge/Services/NuGetPackagePublishService.cs index 079674d1..c6d39d0d 100644 --- a/PowerForge/Services/NuGetPackagePublishService.cs +++ b/PowerForge/Services/NuGetPackagePublishService.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; @@ -96,103 +94,11 @@ public NuGetPackagePublishResult Execute(NuGetPackagePublishRequest request, Fun private static DotNetRepositoryReleaseService.PackagePushResult PushPackage(string packagePath, string apiKey, string source, bool skipDuplicate) { - var psi = new ProcessStartInfo - { - FileName = "dotnet", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - ProcessStartInfoEncoding.TryApplyUtf8(psi); - -#if NET472 - var args = new List - { - "nuget", "push", packagePath, - "--api-key", apiKey, - "--source", source - }; - if (skipDuplicate) - args.Add("--skip-duplicate"); - psi.Arguments = BuildWindowsArgumentString(args); -#else - psi.ArgumentList.Add("nuget"); - psi.ArgumentList.Add("push"); - psi.ArgumentList.Add(packagePath); - psi.ArgumentList.Add("--api-key"); - psi.ArgumentList.Add(apiKey); - psi.ArgumentList.Add("--source"); - psi.ArgumentList.Add(source); - if (skipDuplicate) - psi.ArgumentList.Add("--skip-duplicate"); -#endif - - using var process = Process.Start(psi); - if (process is null) - { - return new DotNetRepositoryReleaseService.PackagePushResult - { - Outcome = DotNetRepositoryReleaseService.PackagePushOutcome.Failed, - Message = "Failed to start dotnet." - }; - } - - var stdOut = process.StandardOutput.ReadToEnd(); - var stdErr = process.StandardError.ReadToEnd(); - process.WaitForExit(); - return DotNetRepositoryReleaseService.ClassifyNuGetPushOutcome(process.ExitCode, skipDuplicate, stdErr, stdOut); - } - -#if NET472 - private static string BuildWindowsArgumentString(IEnumerable arguments) - => string.Join(" ", arguments.Select(EscapeWindowsArgument)); - - private static string EscapeWindowsArgument(string arg) - { - if (arg is null) - return "\"\""; - if (arg.Length == 0) - return "\"\""; - - var needsQuotes = arg.Any(ch => char.IsWhiteSpace(ch) || ch == '"'); - if (!needsQuotes) - return arg; - - var sb = new System.Text.StringBuilder(); - sb.Append('"'); - - var backslashCount = 0; - foreach (var ch in arg) - { - if (ch == '\\') - { - backslashCount++; - continue; - } - - if (ch == '"') - { - sb.Append('\\', backslashCount * 2 + 1); - sb.Append('"'); - backslashCount = 0; - continue; - } - - if (backslashCount > 0) - { - sb.Append('\\', backslashCount); - backslashCount = 0; - } - - sb.Append(ch); - } - - if (backslashCount > 0) - sb.Append('\\', backslashCount * 2); + var result = new DotNetNuGetClient() + .PushPackageAsync(new DotNetNuGetPushRequest(packagePath, apiKey, source, skipDuplicate)) + .GetAwaiter() + .GetResult(); - sb.Append('"'); - return sb.ToString(); + return DotNetRepositoryReleaseService.ClassifyNuGetPushOutcome(result.ExitCode, skipDuplicate, result.StdErr, result.StdOut); } -#endif } diff --git a/PowerForge/Services/PowerShellRepositoryResolver.cs b/PowerForge/Services/PowerShellRepositoryResolver.cs new file mode 100644 index 00000000..da7771a7 --- /dev/null +++ b/PowerForge/Services/PowerShellRepositoryResolver.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace PowerForge; + +/// +/// Resolves registered PowerShell repository metadata using PSResourceGet or PowerShellGet. +/// +public sealed class PowerShellRepositoryResolver +{ + private static readonly JsonSerializerOptions JsonOptions = new() { + PropertyNameCaseInsensitive = true + }; + + private readonly IPowerShellRunner _powerShellRunner; + + /// + /// Creates a new resolver using the default PowerShell runner. + /// + public PowerShellRepositoryResolver() + : this(new PowerShellRunner()) + { + } + + internal PowerShellRepositoryResolver(IPowerShellRunner powerShellRunner) + { + _powerShellRunner = powerShellRunner ?? throw new ArgumentNullException(nameof(powerShellRunner)); + } + + /// + /// Resolves repository metadata for the provided repository name or URI. + /// + public async Task ResolveAsync(string workingDirectory, string repositoryName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName); + + if (Uri.TryCreate(repositoryName, UriKind.Absolute, out var directUri)) + { + return new PowerShellRepositoryResolution { + Name = repositoryName, + SourceUri = directUri.AbsoluteUri + }; + } + + var script = string.Join(Environment.NewLine, new[] { + "$ErrorActionPreference = 'Stop'", + $"$name = {QuoteLiteral(repositoryName)}", + "$psResourceRepo = Get-Command -Name Get-PSResourceRepository -ErrorAction SilentlyContinue", + "if ($null -ne $psResourceRepo) {", + " $repo = Get-PSResourceRepository -Name $name -ErrorAction SilentlyContinue | Select-Object -First 1", + " if ($null -ne $repo) {", + " @{ Name = $repo.Name; SourceUri = $repo.Uri; PublishUri = $repo.PublishUri } | ConvertTo-Json -Compress", + " exit 0", + " }", + "}", + "$psRepo = Get-PSRepository -Name $name -ErrorAction SilentlyContinue | Select-Object -First 1", + "if ($null -ne $psRepo) {", + " @{ Name = $psRepo.Name; SourceUri = $psRepo.SourceLocation; PublishUri = $psRepo.PublishLocation } | ConvertTo-Json -Compress", + " exit 0", + "}", + "exit 1" + }); + + var startedAt = Stopwatch.StartNew(); + var result = await Task.Run(() => _powerShellRunner.Run(PowerShellRunRequest.ForCommand( + commandText: script, + timeout: TimeSpan.FromMinutes(2), + preferPwsh: !OperatingSystem.IsWindows(), + workingDirectory: workingDirectory, + executableOverride: Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"))), cancellationToken).ConfigureAwait(false); + startedAt.Stop(); + + if (result.ExitCode != 0 || string.IsNullOrWhiteSpace(result.StdOut)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(result.StdOut.Trim(), JsonOptions); + } + catch + { + return null; + } + } + + private static string QuoteLiteral(string value) + => $"'{(value ?? string.Empty).Replace("'", "''")}'"; +} diff --git a/PowerForge/Services/ProjectBuildCommandHostService.cs b/PowerForge/Services/ProjectBuildCommandHostService.cs new file mode 100644 index 00000000..61bd9d4f --- /dev/null +++ b/PowerForge/Services/ProjectBuildCommandHostService.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using System.Text; + +namespace PowerForge; + +/// +/// Shared PowerShell-host fallback for invoking Invoke-ProjectBuild when config-backed C# execution is not available. +/// +public sealed class ProjectBuildCommandHostService +{ + private readonly IPowerShellRunner _powerShellRunner; + + /// + /// Creates a new service using the default PowerShell runner. + /// + public ProjectBuildCommandHostService() + : this(new PowerShellRunner()) + { + } + + internal ProjectBuildCommandHostService(IPowerShellRunner powerShellRunner) + { + _powerShellRunner = powerShellRunner ?? throw new ArgumentNullException(nameof(powerShellRunner)); + } + + /// + /// Generates a plan through Invoke-ProjectBuild -Plan. + /// + public Task GeneratePlanAsync(ProjectBuildCommandPlanRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ValidateRequiredPath(request.RepositoryRoot, nameof(request.RepositoryRoot)); + ValidateRequiredPath(request.PlanOutputPath, nameof(request.PlanOutputPath)); + ValidateRequiredPath(request.ModulePath, nameof(request.ModulePath)); + + var command = new StringBuilder("Invoke-ProjectBuild -Plan:$true -PlanPath "); + command.Append(QuoteLiteral(request.PlanOutputPath)); + if (!string.IsNullOrWhiteSpace(request.ConfigPath)) + { + command.Append(" -ConfigPath ").Append(QuoteLiteral(request.ConfigPath)); + } + + return RunCommandAsync( + request.RepositoryRoot, + BuildScript(request.RepositoryRoot, request.ModulePath, command.ToString()), + cancellationToken); + } + + /// + /// Executes a build through Invoke-ProjectBuild -Build with publish disabled. + /// + public Task ExecuteBuildAsync(ProjectBuildCommandBuildRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ValidateRequiredPath(request.RepositoryRoot, nameof(request.RepositoryRoot)); + ValidateRequiredPath(request.ModulePath, nameof(request.ModulePath)); + + var command = new StringBuilder("Invoke-ProjectBuild -Build:$true -PublishNuget:$false -PublishGitHub:$false -UpdateVersions:$false"); + if (!string.IsNullOrWhiteSpace(request.ConfigPath)) + { + command.Append(" -ConfigPath ").Append(QuoteLiteral(request.ConfigPath)); + } + + return RunCommandAsync( + request.RepositoryRoot, + BuildScript(request.RepositoryRoot, request.ModulePath, command.ToString()), + cancellationToken); + } + + private async Task RunCommandAsync(string workingDirectory, string script, CancellationToken cancellationToken) + { + var startedAt = Stopwatch.StartNew(); + var result = await Task.Run(() => _powerShellRunner.Run(PowerShellRunRequest.ForCommand( + commandText: script, + timeout: TimeSpan.FromMinutes(15), + preferPwsh: !OperatingSystem.IsWindows(), + workingDirectory: workingDirectory, + executableOverride: Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"))), cancellationToken).ConfigureAwait(false); + startedAt.Stop(); + + return new ProjectBuildCommandHostExecutionResult { + ExitCode = result.ExitCode, + Duration = startedAt.Elapsed, + StandardOutput = result.StdOut, + StandardError = result.StdErr, + Executable = result.Executable + }; + } + + private static string BuildScript(string repositoryRoot, string modulePath, string command) + => string.Join(Environment.NewLine, new[] { + "$ErrorActionPreference = 'Stop'", + BuildModuleImportClause(modulePath), + $"Set-Location -LiteralPath {QuoteLiteral(repositoryRoot)}", + command + }); + + private static string BuildModuleImportClause(string modulePath) + => File.Exists(modulePath) + ? $"try {{ Import-Module {QuoteLiteral(modulePath)} -Force -ErrorAction Stop }} catch {{ Import-Module PSPublishModule -Force -ErrorAction Stop }}" + : "Import-Module PSPublishModule -Force -ErrorAction Stop"; + + private static string QuoteLiteral(string value) + => $"'{(value ?? string.Empty).Replace("'", "''")}'"; + + private static void ValidateRequiredPath(string value, string argumentName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException($"{argumentName} is required.", argumentName); + } +} diff --git a/PowerForge/Services/ProjectBuildHostService.cs b/PowerForge/Services/ProjectBuildHostService.cs new file mode 100644 index 00000000..c62d6113 --- /dev/null +++ b/PowerForge/Services/ProjectBuildHostService.cs @@ -0,0 +1,128 @@ +namespace PowerForge; + +/// +/// Host-facing service for planning or executing repository project builds from project.build.json. +/// +public sealed class ProjectBuildHostService +{ + private readonly ILogger _logger; + private readonly Func? _executeRelease; + private readonly Func? _publishGitHub; + private readonly Func? _validateGitHubPreflight; + + /// + /// Creates a new host service using a null logger. + /// + public ProjectBuildHostService() + : this(new NullLogger()) + { + } + + /// + /// Creates a new host service using the provided logger. + /// + /// Logger used by the underlying workflow. + public ProjectBuildHostService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + internal ProjectBuildHostService( + ILogger logger, + Func? executeRelease, + Func? publishGitHub, + Func? validateGitHubPreflight) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _executeRelease = executeRelease; + _publishGitHub = publishGitHub; + _validateGitHubPreflight = validateGitHubPreflight; + } + + /// + /// Executes the requested project build workflow. + /// + /// Host execution request. + /// Execution result including resolved paths and the underlying workflow result. + public ProjectBuildHostExecutionResult Execute(ProjectBuildHostRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.ConfigPath)) + throw new ArgumentException("ConfigPath is required.", nameof(request)); + + var startedAt = DateTimeOffset.UtcNow; + var configPath = Path.GetFullPath(request.ConfigPath.Trim().Trim('"')); + var configDirectory = Path.GetDirectoryName(configPath); + if (string.IsNullOrWhiteSpace(configDirectory)) + throw new InvalidOperationException($"Unable to resolve the configuration directory for '{configPath}'."); + + var support = new ProjectBuildSupportService(_logger); + var config = support.LoadConfig(configPath); + return ExecuteCore(request, config, configPath, configDirectory, startedAt); + } + + /// + /// Executes the requested project build workflow using an already loaded configuration object. + /// + /// Host execution request. + /// Loaded configuration to execute. + /// Source configuration path used for resolving relative values and reporting. + internal ProjectBuildHostExecutionResult Execute(ProjectBuildHostRequest request, ProjectBuildConfiguration config, string configPath) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (config is null) + throw new ArgumentNullException(nameof(config)); + if (string.IsNullOrWhiteSpace(configPath)) + throw new ArgumentException("Config path is required.", nameof(configPath)); + + var startedAt = DateTimeOffset.UtcNow; + var fullConfigPath = Path.GetFullPath(configPath.Trim().Trim('"')); + var configDirectory = Path.GetDirectoryName(fullConfigPath); + if (string.IsNullOrWhiteSpace(configDirectory)) + throw new InvalidOperationException($"Unable to resolve the configuration directory for '{fullConfigPath}'."); + + return ExecuteCore(request, config, fullConfigPath, configDirectory, startedAt); + } + + private ProjectBuildHostExecutionResult ExecuteCore( + ProjectBuildHostRequest request, + ProjectBuildConfiguration config, + string configPath, + string configDirectory, + DateTimeOffset startedAt) + { + var preparation = new ProjectBuildPreparationService().Prepare( + config, + configDirectory, + request.PlanOutputPath, + new ProjectBuildRequestedActions { + PlanOnly = request.PlanOnly, + UpdateVersions = request.UpdateVersions, + Build = request.Build, + PublishNuget = request.PublishNuget, + PublishGitHub = request.PublishGitHub + }); + + var workflow = new ProjectBuildWorkflowService( + _logger, + _executeRelease, + _publishGitHub, + _validateGitHubPreflight) + .Execute(config, configDirectory, preparation, request.ExecuteBuild); + + return new ProjectBuildHostExecutionResult { + Success = workflow.Result.Success, + ErrorMessage = workflow.Result.ErrorMessage, + ConfigPath = configPath, + RootPath = preparation.RootPath, + StagingPath = preparation.StagingPath, + OutputPath = preparation.OutputPath, + ReleaseZipOutputPath = preparation.ReleaseZipOutputPath, + PlanOutputPath = preparation.PlanOutputPath, + Duration = DateTimeOffset.UtcNow - startedAt, + Result = workflow.Result + }; + } +} diff --git a/PowerForge/Services/ProjectBuildPublishHostService.cs b/PowerForge/Services/ProjectBuildPublishHostService.cs new file mode 100644 index 00000000..b48f2dc1 --- /dev/null +++ b/PowerForge/Services/ProjectBuildPublishHostService.cs @@ -0,0 +1,108 @@ +namespace PowerForge; + +/// +/// Host-facing service for resolving project publish settings and invoking shared GitHub publish logic. +/// +public sealed class ProjectBuildPublishHostService +{ + private readonly ILogger _logger; + private readonly Func? _publishGitHub; + + /// + /// Creates a new host service using a null logger. + /// + public ProjectBuildPublishHostService() + : this(new NullLogger()) + { + } + + /// + /// Creates a new host service using the provided logger. + /// + public ProjectBuildPublishHostService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + internal ProjectBuildPublishHostService( + ILogger logger, + Func? publishGitHub) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _publishGitHub = publishGitHub; + } + + /// + /// Loads publish-related settings from project.build.json and resolves secrets. + /// + public ProjectBuildPublishHostConfiguration LoadConfiguration(string configPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configPath); + + var resolvedConfigPath = Path.GetFullPath(configPath.Trim().Trim('"')); + var configDirectory = Path.GetDirectoryName(resolvedConfigPath); + if (string.IsNullOrWhiteSpace(configDirectory)) + throw new InvalidOperationException($"Unable to resolve the configuration directory for '{resolvedConfigPath}'."); + + var config = new ProjectBuildSupportService(_logger).LoadConfig(resolvedConfigPath); + return new ProjectBuildPublishHostConfiguration { + ConfigPath = resolvedConfigPath, + PublishNuget = config.PublishNuget == true, + PublishGitHub = config.PublishGitHub == true, + PublishSource = string.IsNullOrWhiteSpace(config.PublishSource) + ? "https://api.nuget.org/v3/index.json" + : config.PublishSource.Trim(), + PublishApiKey = ProjectBuildSupportService.ResolveSecret( + config.PublishApiKey, + config.PublishApiKeyFilePath, + config.PublishApiKeyEnvName, + configDirectory), + GitHubToken = ProjectBuildSupportService.ResolveSecret( + config.GitHubAccessToken, + config.GitHubAccessTokenFilePath, + config.GitHubAccessTokenEnvName, + configDirectory), + GitHubUsername = TrimOrNull(config.GitHubUsername), + GitHubRepositoryName = TrimOrNull(config.GitHubRepositoryName), + GitHubIsPreRelease = config.GitHubIsPreRelease, + GitHubIncludeProjectNameInTag = config.GitHubIncludeProjectNameInTag, + GitHubGenerateReleaseNotes = config.GitHubGenerateReleaseNotes, + GitHubReleaseName = TrimOrNull(config.GitHubReleaseName), + GitHubTagName = TrimOrNull(config.GitHubTagName), + GitHubTagTemplate = TrimOrNull(config.GitHubTagTemplate), + GitHubReleaseMode = string.IsNullOrWhiteSpace(config.GitHubReleaseMode) ? "Single" : config.GitHubReleaseMode.Trim(), + GitHubPrimaryProject = TrimOrNull(config.GitHubPrimaryProject), + GitHubTagConflictPolicy = TrimOrNull(config.GitHubTagConflictPolicy) + }; + } + + /// + /// Publishes GitHub releases for the provided project release plan using shared PowerForge logic. + /// + public ProjectBuildGitHubPublishSummary PublishGitHub(ProjectBuildPublishHostConfiguration configuration, DotNetRepositoryReleaseResult release) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(release); + + var request = new ProjectBuildGitHubPublishRequest { + Owner = configuration.GitHubUsername ?? string.Empty, + Repository = configuration.GitHubRepositoryName ?? string.Empty, + Token = configuration.GitHubToken ?? string.Empty, + Release = release, + ReleaseMode = configuration.GitHubReleaseMode, + IncludeProjectNameInTag = configuration.GitHubIncludeProjectNameInTag, + IsPreRelease = configuration.GitHubIsPreRelease, + GenerateReleaseNotes = configuration.GitHubGenerateReleaseNotes, + ReleaseName = configuration.GitHubReleaseName, + TagName = configuration.GitHubTagName, + TagTemplate = configuration.GitHubTagTemplate, + PrimaryProject = configuration.GitHubPrimaryProject, + TagConflictPolicy = configuration.GitHubTagConflictPolicy + }; + + return (_publishGitHub ?? (publishRequest => new ProjectBuildGitHubPublisher(_logger).Publish(publishRequest)))(request); + } + + private static string? TrimOrNull(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/PowerForge/Services/PublishVerificationHostService.cs b/PowerForge/Services/PublishVerificationHostService.cs new file mode 100644 index 00000000..c565854e --- /dev/null +++ b/PowerForge/Services/PublishVerificationHostService.cs @@ -0,0 +1,388 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace PowerForge; + +/// +/// Shared host service for verifying published GitHub, NuGet, and PowerShell repository targets. +/// +public sealed class PublishVerificationHostService : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient _httpClient; + private readonly PowerShellRepositoryResolver _powerShellRepositoryResolver; + private readonly ModuleManifestMetadataReader _moduleManifestMetadataReader; + private readonly bool _ownsHttpClient; + + /// + /// Creates a new verification host service with a default . + /// + public PublishVerificationHostService() + : this( + new HttpClient(new HttpClientHandler { + AllowAutoRedirect = true + }) { + Timeout = TimeSpan.FromSeconds(20) + }, + new PowerShellRepositoryResolver(), + new ModuleManifestMetadataReader(), + ownsHttpClient: true) + { + } + + /// + /// Creates a new verification host service using the provided HTTP client and repository resolver. + /// + public PublishVerificationHostService( + HttpClient httpClient, + PowerShellRepositoryResolver powerShellRepositoryResolver) + : this(httpClient, powerShellRepositoryResolver, new ModuleManifestMetadataReader(), ownsHttpClient: false) + { + } + + internal PublishVerificationHostService( + HttpClient httpClient, + PowerShellRepositoryResolver powerShellRepositoryResolver, + ModuleManifestMetadataReader moduleManifestMetadataReader) + : this(httpClient, powerShellRepositoryResolver, moduleManifestMetadataReader, ownsHttpClient: false) + { + } + + internal PublishVerificationHostService( + HttpClient httpClient, + PowerShellRepositoryResolver powerShellRepositoryResolver, + ModuleManifestMetadataReader moduleManifestMetadataReader, + bool ownsHttpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _powerShellRepositoryResolver = powerShellRepositoryResolver ?? throw new ArgumentNullException(nameof(powerShellRepositoryResolver)); + _moduleManifestMetadataReader = moduleManifestMetadataReader ?? throw new ArgumentNullException(nameof(moduleManifestMetadataReader)); + _ownsHttpClient = ownsHttpClient; + + if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("PowerForgeStudio/0.1"); + } + } + + /// + /// Verifies a previously published target. + /// + public Task VerifyAsync(PublishVerificationRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + return request.TargetKind switch + { + "GitHub" => VerifyGitHubAsync(request, cancellationToken), + "NuGet" => VerifyNuGetAsync(request, cancellationToken), + "PowerShellRepository" => VerifyPowerShellRepositoryAsync(request, cancellationToken), + _ => Task.FromResult(new PublishVerificationResult { + Status = PublishVerificationStatus.Skipped, + Summary = $"Verification is not implemented for {request.TargetKind} targets yet." + }) + }; + } + + /// + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + private async Task VerifyGitHubAsync(PublishVerificationRequest request, CancellationToken cancellationToken) + { + if (!Uri.TryCreate(request.Destination, UriKind.Absolute, out var uri)) + { + return Failed("GitHub destination URL was not recorded."); + } + + var response = await SendProbeAsync(uri, cancellationToken).ConfigureAwait(false); + if (!response.Succeeded) + { + return Failed("GitHub release probe did not return a success status."); + } + + var statusCode = response.StatusCode.GetValueOrDefault(); + return Verified($"GitHub release probe succeeded with {(int)statusCode} {statusCode}."); + } + + private async Task VerifyNuGetAsync(PublishVerificationRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.SourcePath) || !File.Exists(request.SourcePath)) + { + return Failed("NuGet package path is missing or no longer exists locally."); + } + + if (string.IsNullOrWhiteSpace(request.Destination)) + { + return Skipped("NuGet destination URL was not recorded, so remote verification was skipped."); + } + + var identity = TryReadPackageIdentity(request.SourcePath); + if (identity is null) + { + return Failed("NuGet package identity could not be read from the .nupkg."); + } + + var probeUri = await ResolveNuGetPackageProbeUriAsync(request.Destination, identity, cancellationToken).ConfigureAwait(false); + if (probeUri is null) + { + return Skipped($"PowerForgeStudio could not derive a probeable package endpoint from {request.Destination}."); + } + + var response = await SendProbeAsync(probeUri, cancellationToken).ConfigureAwait(false); + if (!response.Succeeded) + { + return Failed($"Package probe failed for {identity.Id} {identity.Version} against {probeUri.Host}."); + } + + return Verified($"Package probe succeeded for {identity.Id} {identity.Version} against {probeUri.Host}."); + } + + private async Task VerifyPowerShellRepositoryAsync(PublishVerificationRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.SourcePath) || !Directory.Exists(request.SourcePath)) + { + return Failed("Module package path is missing or no longer exists locally."); + } + + var manifestPath = Directory.EnumerateFiles(request.SourcePath, "*.psd1", SearchOption.AllDirectories) + .FirstOrDefault(path => !path.Contains($"{Path.DirectorySeparatorChar}en-US{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrWhiteSpace(manifestPath)) + { + return Failed("Module manifest was not found for PSGallery verification."); + } + + ModuleManifestMetadata metadata; + try + { + metadata = _moduleManifestMetadataReader.Read(manifestPath); + } + catch + { + return Failed("Module manifest could not be read for PSGallery verification."); + } + + var moduleVersion = string.IsNullOrWhiteSpace(metadata.PreRelease) + ? metadata.ModuleVersion + : $"{metadata.ModuleVersion}-{metadata.PreRelease}"; + var destination = request.Destination ?? "PSGallery"; + if (destination.Equals("PSGallery", StringComparison.OrdinalIgnoreCase)) + { + var url = new Uri($"https://www.powershellgallery.com/packages/{metadata.ModuleName}/{moduleVersion}"); + var galleryResponse = await SendProbeAsync(url, cancellationToken).ConfigureAwait(false); + if (!galleryResponse.Succeeded) + { + return Failed($"PSGallery probe failed for {metadata.ModuleName} {moduleVersion}."); + } + + return Verified($"PSGallery probe succeeded for {metadata.ModuleName} {moduleVersion}."); + } + + var resolvedRepository = await _powerShellRepositoryResolver.ResolveAsync(request.RootPath, destination, cancellationToken).ConfigureAwait(false); + if (resolvedRepository is null) + { + return Failed($"PowerShell repository '{destination}' could not be resolved to a probeable endpoint."); + } + + var probeUri = await ResolveNuGetPackageProbeUriAsync( + resolvedRepository.SourceUri ?? resolvedRepository.PublishUri ?? destination, + new NuGetPackageIdentity(metadata.ModuleName, moduleVersion), + cancellationToken).ConfigureAwait(false); + if (probeUri is null) + { + return Skipped($"PowerForgeStudio could not derive a probeable package endpoint from {resolvedRepository.DisplaySource}."); + } + + var probeResponse = await SendProbeAsync(probeUri, cancellationToken).ConfigureAwait(false); + if (!probeResponse.Succeeded) + { + return Failed($"Repository probe failed for {metadata.ModuleName} {moduleVersion} against {probeUri.Host}."); + } + + return Verified($"Repository probe succeeded for {metadata.ModuleName} {moduleVersion} against {probeUri.Host}."); + } + + private async Task ResolveNuGetPackageProbeUriAsync(string destination, NuGetPackageIdentity identity, CancellationToken cancellationToken) + { + if (!Uri.TryCreate(destination, UriKind.Absolute, out var destinationUri)) + { + return null; + } + + if (destinationUri.Host.Contains("nuget.org", StringComparison.OrdinalIgnoreCase)) + { + return BuildFlatContainerPackageUri(new Uri("https://api.nuget.org/v3-flatcontainer/"), identity); + } + + if (destinationUri.AbsolutePath.Contains("/v3-flatcontainer", StringComparison.OrdinalIgnoreCase) || + destinationUri.AbsolutePath.Contains("/flatcontainer", StringComparison.OrdinalIgnoreCase)) + { + return BuildFlatContainerPackageUri(destinationUri, identity); + } + + if (destinationUri.AbsolutePath.EndsWith("/index.json", StringComparison.OrdinalIgnoreCase) || + destinationUri.AbsolutePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + var packageBaseUri = await ResolvePackageBaseAddressAsync(destinationUri, cancellationToken).ConfigureAwait(false); + return packageBaseUri is null ? null : BuildFlatContainerPackageUri(packageBaseUri, identity); + } + + return null; + } + + private async Task ResolvePackageBaseAddressAsync(Uri serviceIndexUri, CancellationToken cancellationToken) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, serviceIndexUri); + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if ((int)response.StatusCode >= 400) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + if (!document.RootElement.TryGetProperty("resources", out var resources) || resources.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var resource in resources.EnumerateArray()) + { + if (!resource.TryGetProperty("@type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) + { + continue; + } + + var type = typeElement.GetString(); + if (string.IsNullOrWhiteSpace(type) || + !type.StartsWith("PackageBaseAddress/", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!resource.TryGetProperty("@id", out var idElement) || idElement.ValueKind != JsonValueKind.String) + { + continue; + } + + var id = idElement.GetString(); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + if (Uri.TryCreate(serviceIndexUri, id, out var resolved)) + { + return resolved; + } + } + } + catch + { + return null; + } + + return null; + } + + private async Task SendProbeAsync(Uri uri, CancellationToken cancellationToken) + { + using var headRequest = new HttpRequestMessage(HttpMethod.Head, uri); + try + { + using var headResponse = await _httpClient.SendAsync(headRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if ((int)headResponse.StatusCode < 400) + { + return new ProbeResponse(true, headResponse.StatusCode); + } + } + catch + { + // Fall back to GET. + } + + using var getRequest = new HttpRequestMessage(HttpMethod.Get, uri); + try + { + using var getResponse = await _httpClient.SendAsync(getRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + return (int)getResponse.StatusCode < 400 + ? new ProbeResponse(true, getResponse.StatusCode) + : ProbeResponse.Failed; + } + catch + { + return ProbeResponse.Failed; + } + } + + private static Uri BuildFlatContainerPackageUri(Uri baseUri, NuGetPackageIdentity identity) + { + var builder = new StringBuilder(baseUri.AbsoluteUri.TrimEnd('/')); + builder.Append('/'); + builder.Append(Uri.EscapeDataString(identity.Id.ToLowerInvariant())); + builder.Append('/'); + builder.Append(Uri.EscapeDataString(identity.Version.ToLowerInvariant())); + builder.Append('/'); + builder.Append(Uri.EscapeDataString(identity.Id.ToLowerInvariant())); + builder.Append('.'); + builder.Append(Uri.EscapeDataString(identity.Version.ToLowerInvariant())); + builder.Append(".nupkg"); + return new Uri(builder.ToString(), UriKind.Absolute); + } + + private static NuGetPackageIdentity? TryReadPackageIdentity(string packagePath) + { + try + { + using var archive = ZipFile.OpenRead(packagePath); + var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + if (nuspecEntry is null) + { + return null; + } + + using var stream = nuspecEntry.Open(); + using var reader = new StreamReader(stream); + var xml = System.Xml.Linq.XDocument.Load(reader); + var metadata = xml.Root?.Elements().FirstOrDefault(element => element.Name.LocalName.Equals("metadata", StringComparison.OrdinalIgnoreCase)); + var id = metadata?.Elements().FirstOrDefault(element => element.Name.LocalName.Equals("id", StringComparison.OrdinalIgnoreCase))?.Value; + var version = metadata?.Elements().FirstOrDefault(element => element.Name.LocalName.Equals("version", StringComparison.OrdinalIgnoreCase))?.Value; + return string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(version) + ? null + : new NuGetPackageIdentity(id.Trim(), version.Trim()); + } + catch + { + return null; + } + } + + private static PublishVerificationResult Verified(string summary) + => new() { Status = PublishVerificationStatus.Verified, Summary = summary }; + + private static PublishVerificationResult Failed(string summary) + => new() { Status = PublishVerificationStatus.Failed, Summary = summary }; + + private static PublishVerificationResult Skipped(string summary) + => new() { Status = PublishVerificationStatus.Skipped, Summary = summary }; + + private sealed record NuGetPackageIdentity(string Id, string Version); + + private readonly record struct ProbeResponse(bool Succeeded, HttpStatusCode? StatusCode) + { + public static ProbeResponse Failed => new(false, null); + } +} diff --git a/PowerForge/Services/ReleaseSigningHostSettingsResolver.cs b/PowerForge/Services/ReleaseSigningHostSettingsResolver.cs new file mode 100644 index 00000000..55a1c79b --- /dev/null +++ b/PowerForge/Services/ReleaseSigningHostSettingsResolver.cs @@ -0,0 +1,74 @@ +namespace PowerForge; + +/// +/// Resolves host-facing signing settings from environment variables and shared module discovery. +/// +public sealed class ReleaseSigningHostSettingsResolver +{ + private readonly Func _getEnvironmentVariable; + private readonly Func _resolveModulePath; + + /// + /// Creates a new resolver using process environment variables. + /// + public ReleaseSigningHostSettingsResolver() + : this(Environment.GetEnvironmentVariable, static () => string.Empty) + { + } + + /// + /// Creates a new resolver using process environment variables and the provided module path resolver. + /// + public ReleaseSigningHostSettingsResolver(Func resolveModulePath) + : this(Environment.GetEnvironmentVariable, resolveModulePath) + { + } + + internal ReleaseSigningHostSettingsResolver( + Func getEnvironmentVariable, + Func resolveModulePath) + { + _getEnvironmentVariable = getEnvironmentVariable ?? throw new ArgumentNullException(nameof(getEnvironmentVariable)); + _resolveModulePath = resolveModulePath ?? throw new ArgumentNullException(nameof(resolveModulePath)); + } + + /// + /// Resolves signing settings for Studio/host orchestration. + /// + public ReleaseSigningHostSettings Resolve() + { + var thumbprint = TrimOrNull(_getEnvironmentVariable("RELEASE_OPS_STUDIO_SIGN_THUMBPRINT")); + var storeName = TrimOrDefault(_getEnvironmentVariable("RELEASE_OPS_STUDIO_SIGN_STORE"), "CurrentUser"); + var timeStampServer = TrimOrDefault(_getEnvironmentVariable("RELEASE_OPS_STUDIO_SIGN_TIMESTAMP_URL"), "http://timestamp.digicert.com"); + var modulePath = TrimOrNull(_getEnvironmentVariable("RELEASE_OPS_STUDIO_PSPUBLISHMODULE_PATH")); + + modulePath = string.IsNullOrWhiteSpace(modulePath) + ? _resolveModulePath() + : modulePath; + + if (string.IsNullOrWhiteSpace(thumbprint)) + { + return new ReleaseSigningHostSettings { + IsConfigured = false, + StoreName = storeName, + TimeStampServer = timeStampServer, + ModulePath = modulePath, + MissingConfigurationMessage = "Signing is not configured. Set RELEASE_OPS_STUDIO_SIGN_THUMBPRINT first." + }; + } + + return new ReleaseSigningHostSettings { + IsConfigured = true, + Thumbprint = thumbprint, + StoreName = storeName, + TimeStampServer = timeStampServer, + ModulePath = modulePath + }; + } + + private static string? TrimOrNull(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string TrimOrDefault(string? value, string defaultValue) + => string.IsNullOrWhiteSpace(value) ? defaultValue : value.Trim(); +} diff --git a/PowerForge/Services/RunnerHousekeepingService.cs b/PowerForge/Services/RunnerHousekeepingService.cs new file mode 100644 index 00000000..8a7e0696 --- /dev/null +++ b/PowerForge/Services/RunnerHousekeepingService.cs @@ -0,0 +1,569 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace PowerForge; + +/// +/// Reclaims disk space on GitHub-hosted or self-hosted runners using conservative defaults. +/// +public sealed class RunnerHousekeepingService +{ + private readonly ILogger _logger; + + /// + /// Creates a runner housekeeping service with a logger. + /// + /// Logger used for progress and diagnostics. + public RunnerHousekeepingService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Executes a housekeeping run against the current runner filesystem. + /// + /// Cleanup specification. + /// Run summary with step details and free-space counters. + public RunnerHousekeepingResult Clean(RunnerHousekeepingSpec spec) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var normalized = NormalizeSpec(spec); + var steps = new List(); + var freeBefore = GetFreeBytes(normalized.FreeSpaceProbePath); + var aggressiveApplied = normalized.Aggressive || (normalized.AggressiveThresholdBytes.HasValue && freeBefore < normalized.AggressiveThresholdBytes.Value); + + _logger.Info($"Runner root detected as: {normalized.RunnerRootPath}"); + _logger.Info($"Free disk before cleanup: {FormatGiB(freeBefore)} GiB"); + + if (normalized.RequiredFreeBytes.HasValue) + _logger.Info($"Required free disk after cleanup: {FormatGiB(normalized.RequiredFreeBytes.Value)} GiB"); + + if (normalized.AggressiveThresholdBytes.HasValue) + _logger.Info($"Aggressive cleanup threshold: < {FormatGiB(normalized.AggressiveThresholdBytes.Value)} GiB"); + + if (normalized.CleanDiagnostics) + { + steps.Add(DeleteFilesOlderThan( + id: "diag", + title: "Cleanup runner diagnostics", + rootPath: normalized.DiagnosticsRootPath, + retentionDays: normalized.DiagnosticsRetentionDays, + dryRun: normalized.DryRun)); + } + + if (normalized.CleanRunnerTemp) + { + steps.Add(DeleteDirectoryContents( + id: "runner-temp", + title: "Cleanup runner temp", + rootPath: normalized.RunnerTempPath, + dryRun: normalized.DryRun)); + } + + if (aggressiveApplied) + { + if (normalized.CleanActionsCache) + { + steps.Add(DeleteDirectoriesOlderThan( + id: "actions-cache", + title: "Cleanup action working sets", + rootPath: normalized.ActionsRootPath, + retentionDays: normalized.ActionsRetentionDays, + dryRun: normalized.DryRun, + allowSudo: normalized.AllowSudo)); + } + + if (normalized.CleanToolCache) + { + steps.Add(DeleteDirectoriesOlderThan( + id: "tool-cache", + title: "Cleanup runner tool cache", + rootPath: normalized.ToolCachePath, + retentionDays: normalized.ToolCacheRetentionDays, + dryRun: normalized.DryRun, + allowSudo: normalized.AllowSudo)); + } + + if (normalized.ClearDotNetCaches) + { + steps.Add(RunCommand( + id: "dotnet-cache", + title: "Clear dotnet caches", + fileName: "dotnet", + arguments: new[] { "nuget", "locals", "all", "--clear" }, + workingDirectory: normalized.WorkRootPath, + dryRun: normalized.DryRun)); + } + + if (normalized.PruneDocker) + { + var args = normalized.IncludeDockerVolumes + ? new[] { "system", "prune", "-af", "--volumes" } + : new[] { "system", "prune", "-af" }; + + steps.Add(RunCommand( + id: "docker-prune", + title: "Prune Docker data", + fileName: "docker", + arguments: args, + workingDirectory: normalized.WorkRootPath, + dryRun: normalized.DryRun)); + } + } + else + { + _logger.Info("Skipping aggressive cleanup; free disk is healthy."); + } + + var freeAfter = normalized.DryRun ? freeBefore : GetFreeBytes(normalized.FreeSpaceProbePath); + var result = new RunnerHousekeepingResult + { + RunnerRootPath = normalized.RunnerRootPath, + WorkRootPath = normalized.WorkRootPath, + RunnerTempPath = normalized.RunnerTempPath, + DiagnosticsRootPath = normalized.DiagnosticsRootPath, + ToolCachePath = normalized.ToolCachePath, + FreeBytesBefore = freeBefore, + FreeBytesAfter = freeAfter, + RequiredFreeBytes = normalized.RequiredFreeBytes, + AggressiveThresholdBytes = normalized.AggressiveThresholdBytes, + AggressiveApplied = aggressiveApplied, + DryRun = normalized.DryRun, + Steps = steps.ToArray(), + Success = steps.All(s => s.Success || s.Skipped) + }; + + if (!normalized.DryRun) + _logger.Info($"Free disk after cleanup: {FormatGiB(freeAfter)} GiB"); + + if (normalized.RequiredFreeBytes.HasValue && freeAfter < normalized.RequiredFreeBytes.Value) + { + result.Success = false; + result.Message = $"Free disk after cleanup is {FormatGiB(freeAfter)} GiB (required: {FormatGiB(normalized.RequiredFreeBytes.Value)} GiB)."; + } + + return result; + } + + private sealed class NormalizedSpec + { + public string RunnerRootPath { get; set; } = string.Empty; + public string WorkRootPath { get; set; } = string.Empty; + public string RunnerTempPath { get; set; } = string.Empty; + public string? DiagnosticsRootPath { get; set; } + public string? ActionsRootPath { get; set; } + public string? ToolCachePath { get; set; } + public string FreeSpaceProbePath { get; set; } = string.Empty; + public long? RequiredFreeBytes { get; set; } + public long? AggressiveThresholdBytes { get; set; } + public int DiagnosticsRetentionDays { get; set; } + public int ActionsRetentionDays { get; set; } + public int ToolCacheRetentionDays { get; set; } + public bool DryRun { get; set; } + public bool Aggressive { get; set; } + public bool CleanDiagnostics { get; set; } + public bool CleanRunnerTemp { get; set; } + public bool CleanActionsCache { get; set; } + public bool CleanToolCache { get; set; } + public bool ClearDotNetCaches { get; set; } + public bool PruneDocker { get; set; } + public bool IncludeDockerVolumes { get; set; } + public bool AllowSudo { get; set; } + } + + private NormalizedSpec NormalizeSpec(RunnerHousekeepingSpec spec) + { + var runnerTemp = ResolveRunnerTempPath(spec); + if (string.IsNullOrWhiteSpace(runnerTemp) || !Directory.Exists(runnerTemp)) + throw new InvalidOperationException("RUNNER_TEMP is missing or invalid. Provide --runner-temp when running outside GitHub Actions."); + + var workRoot = ResolveWorkRootPath(spec, runnerTemp); + var runnerRoot = ResolveRunnerRootPath(spec, workRoot); + var diagnosticsRoot = ResolvePathOrNull(spec.DiagnosticsRootPath) ?? Path.Combine(runnerRoot, "_diag"); + var actionsRoot = Path.Combine(workRoot, "_actions"); + var toolCache = ResolvePathOrNull(spec.ToolCachePath) + ?? ResolvePathOrNull(Environment.GetEnvironmentVariable("RUNNER_TOOL_CACHE")) + ?? ResolvePathOrNull(Environment.GetEnvironmentVariable("AGENT_TOOLSDIRECTORY")); + + var requiredFreeBytes = spec.MinFreeGb is > 0 ? (long?)ToGiBBytes(spec.MinFreeGb.Value) : null; + var aggressiveThresholdGb = spec.AggressiveThresholdGb + ?? (spec.MinFreeGb is > 0 ? spec.MinFreeGb.Value + 5 : (int?)null); + var aggressiveThresholdBytes = aggressiveThresholdGb is > 0 ? (long?)ToGiBBytes(aggressiveThresholdGb.Value) : null; + + return new NormalizedSpec + { + RunnerRootPath = runnerRoot, + WorkRootPath = workRoot, + RunnerTempPath = runnerTemp, + DiagnosticsRootPath = diagnosticsRoot, + ActionsRootPath = actionsRoot, + ToolCachePath = toolCache, + FreeSpaceProbePath = Directory.Exists(runnerRoot) ? runnerRoot : workRoot, + RequiredFreeBytes = requiredFreeBytes, + AggressiveThresholdBytes = aggressiveThresholdBytes, + DiagnosticsRetentionDays = Math.Max(0, spec.DiagnosticsRetentionDays), + ActionsRetentionDays = Math.Max(0, spec.ActionsRetentionDays), + ToolCacheRetentionDays = Math.Max(0, spec.ToolCacheRetentionDays), + DryRun = spec.DryRun, + Aggressive = spec.Aggressive, + CleanDiagnostics = spec.CleanDiagnostics, + CleanRunnerTemp = spec.CleanRunnerTemp, + CleanActionsCache = spec.CleanActionsCache, + CleanToolCache = spec.CleanToolCache, + ClearDotNetCaches = spec.ClearDotNetCaches, + PruneDocker = spec.PruneDocker, + IncludeDockerVolumes = spec.IncludeDockerVolumes, + AllowSudo = spec.AllowSudo + }; + } + + private static string ResolveRunnerTempPath(RunnerHousekeepingSpec spec) + { + var explicitPath = ResolvePathOrNull(spec.RunnerTempPath); + if (!string.IsNullOrWhiteSpace(explicitPath)) + return explicitPath!; + + return ResolvePathOrNull(Environment.GetEnvironmentVariable("RUNNER_TEMP")) ?? string.Empty; + } + + private static string ResolveWorkRootPath(RunnerHousekeepingSpec spec, string runnerTempPath) + { + var explicitPath = ResolvePathOrNull(spec.WorkRootPath); + if (!string.IsNullOrWhiteSpace(explicitPath)) + return explicitPath!; + + var tempParent = Directory.GetParent(runnerTempPath)?.FullName; + if (!string.IsNullOrWhiteSpace(tempParent) && Directory.Exists(tempParent)) + return tempParent!; + + var workspace = ResolvePathOrNull(Environment.GetEnvironmentVariable("GITHUB_WORKSPACE")); + if (!string.IsNullOrWhiteSpace(workspace)) + { + var repoDir = Directory.GetParent(workspace); + var workRoot = repoDir?.Parent?.FullName; + if (!string.IsNullOrWhiteSpace(workRoot) && Directory.Exists(workRoot)) + return workRoot!; + } + + throw new InvalidOperationException("Unable to determine runner work root. Provide --work-root explicitly."); + } + + private static string ResolveRunnerRootPath(RunnerHousekeepingSpec spec, string workRootPath) + { + var explicitPath = ResolvePathOrNull(spec.RunnerRootPath); + if (!string.IsNullOrWhiteSpace(explicitPath)) + return explicitPath!; + + return Directory.GetParent(workRootPath)?.FullName ?? workRootPath; + } + + private RunnerHousekeepingStepResult DeleteFilesOlderThan(string id, string title, string? rootPath, int retentionDays, bool dryRun) + { + if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath)) + return SkippedStep(id, title, $"Path not found: {rootPath ?? "(null)"}"); + + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + var targets = Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories) + .Where(path => File.GetLastWriteTimeUtc(path) <= cutoff) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return DeleteTargets(id, title, targets, dryRun, allowSudo: false, isDirectory: false); + } + + private RunnerHousekeepingStepResult DeleteDirectoryContents(string id, string title, string? rootPath, bool dryRun) + { + if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath)) + return SkippedStep(id, title, $"Path not found: {rootPath ?? "(null)"}"); + + var targets = Directory.EnumerateFileSystemEntries(rootPath) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return DeleteTargets(id, title, targets, dryRun, allowSudo: false, isDirectory: null); + } + + private RunnerHousekeepingStepResult DeleteDirectoriesOlderThan(string id, string title, string? rootPath, int retentionDays, bool dryRun, bool allowSudo) + { + if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath)) + return SkippedStep(id, title, $"Path not found: {rootPath ?? "(null)"}"); + + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + var targets = Directory.EnumerateDirectories(rootPath, "*", SearchOption.TopDirectoryOnly) + .Where(path => Directory.GetLastWriteTimeUtc(path) <= cutoff) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return DeleteTargets(id, title, targets, dryRun, allowSudo, isDirectory: true); + } + + private RunnerHousekeepingStepResult DeleteTargets(string id, string title, string[] targets, bool dryRun, bool allowSudo, bool? isDirectory) + { + if (targets.Length == 0) + return SkippedStep(id, title, "Nothing to clean."); + + if (dryRun) + { + _logger.Info($"{title}: planned {targets.Length} item(s)."); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + DryRun = true, + EntriesAffected = targets.Length, + Message = $"Planned {targets.Length} item(s).", + Targets = targets + }; + } + + foreach (var target in targets) + { + DeleteTarget(target, allowSudo, isDirectory); + } + + _logger.Info($"{title}: deleted {targets.Length} item(s)."); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + EntriesAffected = targets.Length, + Message = $"Deleted {targets.Length} item(s).", + Targets = targets + }; + } + + private void DeleteTarget(string target, bool allowSudo, bool? isDirectory) + { + try + { + if (isDirectory == true || (isDirectory is null && Directory.Exists(target))) + { + Directory.Delete(target, recursive: true); + return; + } + + if (File.Exists(target)) + File.Delete(target); + } + catch when (allowSudo && CanUseSudo() && (isDirectory == true || Directory.Exists(target))) + { + RunSudoDelete(target); + } + } + + private RunnerHousekeepingStepResult RunCommand(string id, string title, string fileName, IReadOnlyList arguments, string workingDirectory, bool dryRun) + { + if (!CommandExists(fileName)) + return SkippedStep(id, title, $"Command not available: {fileName}"); + + var renderedArgs = string.Join(" ", arguments.Select(QuoteForDisplay)); + var commandText = $"{fileName} {renderedArgs}".Trim(); + + if (dryRun) + { + _logger.Info($"{title}: would run '{commandText}'."); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + DryRun = true, + Command = commandText, + Message = $"Would run '{commandText}'." + }; + } + + var result = RunProcess(fileName, arguments, workingDirectory); + if (result.ExitCode == 0) + { + _logger.Info($"{title}: completed."); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + Command = commandText, + ExitCode = result.ExitCode, + Message = "Completed successfully." + }; + } + + _logger.Warn($"{title}: exit code {result.ExitCode}. {result.StdErr}".Trim()); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + Success = false, + Command = commandText, + ExitCode = result.ExitCode, + Message = string.IsNullOrWhiteSpace(result.StdErr) ? $"Exit code {result.ExitCode}." : result.StdErr.Trim() + }; + } + + private static RunnerHousekeepingStepResult SkippedStep(string id, string title, string message) + { + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + Skipped = true, + Message = message + }; + } + + private static long GetFreeBytes(string path) + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrWhiteSpace(root)) + throw new InvalidOperationException($"Unable to determine filesystem root for path: {path}"); + + var drive = new DriveInfo(root); + return drive.AvailableFreeSpace; + } + + 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? ResolvePathOrNull(string? value) + { + var trimmed = value == null ? string.Empty : value.Trim().Trim('"'); + if (string.IsNullOrWhiteSpace(trimmed)) + return null; + + return Path.GetFullPath(trimmed); + } + + private static bool CommandExists(string fileName) + { + var pathValue = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(pathValue)) + return false; + + var extensions = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT;.COM") + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + : new[] { string.Empty }; + + foreach (var rawSegment in pathValue.Split(new[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries)) + { + var segment = rawSegment.Trim(); + if (string.IsNullOrWhiteSpace(segment) || !Directory.Exists(segment)) + continue; + + foreach (var extension in extensions) + { + var candidate = Path.Combine(segment, fileName + extension); + if (File.Exists(candidate)) + return true; + } + } + + return false; + } + + private static bool CanUseSudo() + => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && CommandExists("sudo"); + + private void RunSudoDelete(string target) + { + 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 (int ExitCode, string StdOut, string StdErr) RunProcess(string fileName, IReadOnlyList arguments, string workingDirectory) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + ProcessStartInfoEncoding.TryApplyUtf8(psi); + +#if NET472 + psi.Arguments = BuildWindowsArgumentString(arguments); +#else + foreach (var arg in arguments) + psi.ArgumentList.Add(arg); +#endif + + using var process = Process.Start(psi); + if (process is null) + return (-1, string.Empty, $"Failed to start process: {fileName}"); + + var stdOut = process.StandardOutput.ReadToEnd(); + var stdErr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, stdOut, stdErr); + } + + private static string QuoteForDisplay(string argument) + { + if (string.IsNullOrWhiteSpace(argument)) + return "\"\""; + + return argument.IndexOfAny(new[] { ' ', '\t', '"' }) >= 0 + ? $"\"{argument.Replace("\"", "\\\"")}\"" + : argument; + } + +#if NET472 + private static string BuildWindowsArgumentString(IEnumerable arguments) + => string.Join(" ", arguments.Select(EscapeWindowsArgument)); + + private static string EscapeWindowsArgument(string arg) + { + if (arg is null) return "\"\""; + if (arg.Length == 0) return "\"\""; + + var needsQuotes = arg.Any(ch => char.IsWhiteSpace(ch) || ch == '"'); + if (!needsQuotes) return arg; + + var sb = new System.Text.StringBuilder(); + sb.Append('"'); + + var backslashCount = 0; + foreach (var ch in arg) + { + if (ch == '\\') + { + backslashCount++; + continue; + } + + if (ch == '"') + { + sb.Append('\\', backslashCount * 2 + 1); + sb.Append('"'); + backslashCount = 0; + continue; + } + + if (backslashCount > 0) + { + sb.Append('\\', backslashCount); + backslashCount = 0; + } + + sb.Append(ch); + } + + if (backslashCount > 0) + sb.Append('\\', backslashCount * 2); + + sb.Append('"'); + return sb.ToString(); + } +#endif +} diff --git a/PowerForgeStudio.Domain/Portfolio/RepositoryGitOperationKind.cs b/PowerForgeStudio.Domain/Portfolio/RepositoryGitOperationKind.cs new file mode 100644 index 00000000..224f7d78 --- /dev/null +++ b/PowerForgeStudio.Domain/Portfolio/RepositoryGitOperationKind.cs @@ -0,0 +1,11 @@ +namespace PowerForgeStudio.Domain.Portfolio; + +public enum RepositoryGitOperationKind +{ + StatusShortBranch = 0, + StatusShort = 1, + ShowTopLevel = 2, + PullRebase = 3, + CreateBranch = 4, + SetUpstream = 5 +} diff --git a/PowerForgeStudio.Domain/Portfolio/RepositoryGitQuickAction.cs b/PowerForgeStudio.Domain/Portfolio/RepositoryGitQuickAction.cs index 3f3b6a41..c04c0ac9 100644 --- a/PowerForgeStudio.Domain/Portfolio/RepositoryGitQuickAction.cs +++ b/PowerForgeStudio.Domain/Portfolio/RepositoryGitQuickAction.cs @@ -6,7 +6,9 @@ public sealed record RepositoryGitQuickAction( RepositoryGitQuickActionKind Kind, string Payload, string ExecuteLabel, - bool IsPrimary = false) + bool IsPrimary = false, + RepositoryGitOperationKind? GitOperation = null, + string? GitOperationArgument = null) { public string KindDisplay => Kind == RepositoryGitQuickActionKind.BrowserUrl ? "Browser" : "Git"; } diff --git a/PowerForgeStudio.Domain/Portfolio/RepositoryGitRemediationStep.cs b/PowerForgeStudio.Domain/Portfolio/RepositoryGitRemediationStep.cs index 630f5ad2..5b0e42a2 100644 --- a/PowerForgeStudio.Domain/Portfolio/RepositoryGitRemediationStep.cs +++ b/PowerForgeStudio.Domain/Portfolio/RepositoryGitRemediationStep.cs @@ -4,4 +4,6 @@ public sealed record RepositoryGitRemediationStep( string Title, string Summary, string CommandText, - bool IsPrimary = false); + bool IsPrimary = false, + RepositoryGitOperationKind? GitOperation = null, + string? GitOperationArgument = null); diff --git a/PowerForgeStudio.Domain/Publish/ReleasePublishReceipt.cs b/PowerForgeStudio.Domain/Publish/ReleasePublishReceipt.cs new file mode 100644 index 00000000..7ffbe5ca --- /dev/null +++ b/PowerForgeStudio.Domain/Publish/ReleasePublishReceipt.cs @@ -0,0 +1,16 @@ +namespace PowerForgeStudio.Domain.Publish; + +public sealed record ReleasePublishReceipt( + string RootPath, + string RepositoryName, + string AdapterKind, + string TargetName, + string TargetKind, + string? Destination, + string? SourcePath, + ReleasePublishReceiptStatus Status, + string Summary, + DateTimeOffset PublishedAtUtc) +{ + public string StatusDisplay => Status.ToString(); +} diff --git a/PowerForgeStudio.Domain/Publish/ReleasePublishReceiptStatus.cs b/PowerForgeStudio.Domain/Publish/ReleasePublishReceiptStatus.cs new file mode 100644 index 00000000..8da8d731 --- /dev/null +++ b/PowerForgeStudio.Domain/Publish/ReleasePublishReceiptStatus.cs @@ -0,0 +1,8 @@ +namespace PowerForgeStudio.Domain.Publish; + +public enum ReleasePublishReceiptStatus +{ + Published = 0, + Skipped = 1, + Failed = 2 +} diff --git a/PowerForgeStudio.Domain/Publish/ReleasePublishTarget.cs b/PowerForgeStudio.Domain/Publish/ReleasePublishTarget.cs new file mode 100644 index 00000000..07bb0804 --- /dev/null +++ b/PowerForgeStudio.Domain/Publish/ReleasePublishTarget.cs @@ -0,0 +1,10 @@ +namespace PowerForgeStudio.Domain.Publish; + +public sealed record ReleasePublishTarget( + string RootPath, + string RepositoryName, + string AdapterKind, + string TargetName, + string TargetKind, + string? SourcePath, + string Destination); diff --git a/PowerForgeStudio.Orchestrator/Git/GitRepositoryInspector.cs b/PowerForgeStudio.Orchestrator/Git/GitRepositoryInspector.cs index 1fca9935..c67788be 100644 --- a/PowerForgeStudio.Orchestrator/Git/GitRepositoryInspector.cs +++ b/PowerForgeStudio.Orchestrator/Git/GitRepositoryInspector.cs @@ -1,10 +1,22 @@ -using System.Diagnostics; +using PowerForge; using PowerForgeStudio.Domain.Portfolio; namespace PowerForgeStudio.Orchestrator.Git; public sealed class GitRepositoryInspector { + private readonly GitClient _gitClient; + + public GitRepositoryInspector() + : this(new GitClient()) + { + } + + public GitRepositoryInspector(GitClient gitClient) + { + _gitClient = gitClient; + } + public RepositoryGitSnapshot Inspect(string repositoryRoot) => InspectAsync(repositoryRoot, CancellationToken.None).GetAwaiter().GetResult(); @@ -17,143 +29,22 @@ public async Task InspectAsync(string repositoryRoot, Can return new RepositoryGitSnapshot(false, null, null, 0, 0, 0, 0); } - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); - - using var process = new Process(); try { - process.StartInfo = new ProcessStartInfo { - FileName = "git", - Arguments = "status --porcelain=2 --branch", - WorkingDirectory = repositoryRoot, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - if (!process.Start()) - { - return new RepositoryGitSnapshot(false, null, null, 0, 0, 0, 0); - } - - var outputTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token); - var errorTask = process.StandardError.ReadToEndAsync(timeoutCts.Token); - await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); - var output = await outputTask.ConfigureAwait(false); - _ = await errorTask.ConfigureAwait(false); - - if (process.ExitCode != 0) - { - return new RepositoryGitSnapshot(false, null, null, 0, 0, 0, 0); - } - - return Parse(output); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) - { - TryKill(process); - return new RepositoryGitSnapshot(false, null, null, 0, 0, 0, 0); + var snapshot = await _gitClient.GetStatusAsync(repositoryRoot, cancellationToken).ConfigureAwait(false); + return new RepositoryGitSnapshot( + snapshot.IsGitRepository, + snapshot.BranchName, + snapshot.UpstreamBranch, + snapshot.AheadCount, + snapshot.BehindCount, + snapshot.TrackedChangeCount, + snapshot.UntrackedChangeCount); } catch { return new RepositoryGitSnapshot(false, null, null, 0, 0, 0, 0); } } - - private static void TryKill(Process? process) - { - if (process is null) - { - return; - } - - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - process.WaitForExit(1000); - } - } - catch - { - // Best effort cleanup only. - } - } - - private static RepositoryGitSnapshot Parse(string output) - { - string? branchName = null; - string? upstreamBranch = null; - var aheadCount = 0; - var behindCount = 0; - var trackedChangeCount = 0; - var untrackedChangeCount = 0; - - foreach (var line in output.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries)) - { - if (line.StartsWith("# branch.head ", StringComparison.Ordinal)) - { - branchName = line["# branch.head ".Length..].Trim(); - continue; - } - - if (line.StartsWith("# branch.upstream ", StringComparison.Ordinal)) - { - upstreamBranch = line["# branch.upstream ".Length..].Trim(); - continue; - } - - if (line.StartsWith("# branch.ab ", StringComparison.Ordinal)) - { - ParseAheadBehind(line["# branch.ab ".Length..], out aheadCount, out behindCount); - continue; - } - - if (line.StartsWith("? ", StringComparison.Ordinal)) - { - untrackedChangeCount++; - continue; - } - - if (line.StartsWith("1 ", StringComparison.Ordinal) - || line.StartsWith("2 ", StringComparison.Ordinal) - || line.StartsWith("u ", StringComparison.Ordinal)) - { - trackedChangeCount++; - } - } - - return new RepositoryGitSnapshot( - IsGitRepository: true, - BranchName: branchName, - UpstreamBranch: upstreamBranch, - AheadCount: aheadCount, - BehindCount: behindCount, - TrackedChangeCount: trackedChangeCount, - UntrackedChangeCount: untrackedChangeCount); - } - - private static void ParseAheadBehind(string value, out int aheadCount, out int behindCount) - { - aheadCount = 0; - behindCount = 0; - - var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries); - foreach (var part in parts) - { - if (part.StartsWith('+') && int.TryParse(part[1..], out var ahead)) - { - aheadCount = ahead; - } - - if (part.StartsWith('-') && int.TryParse(part[1..], out var behind)) - { - behindCount = behind; - } - } - } } diff --git a/PowerForgeStudio.Orchestrator/Host/PowerForgeStudioHostPaths.cs b/PowerForgeStudio.Orchestrator/Host/PowerForgeStudioHostPaths.cs new file mode 100644 index 00000000..d163df17 --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Host/PowerForgeStudioHostPaths.cs @@ -0,0 +1,64 @@ +using System.Text; +using PowerForgeStudio.Orchestrator.PowerShell; + +namespace PowerForgeStudio.Orchestrator.Host; + +public static class PowerForgeStudioHostPaths +{ + public static string GetDefaultDatabasePath() + => Path.Combine(GetStudioRootPath(), "releaseops.db"); + + public static string GetPlansFilePath(string repositoryName, string adapterKind, string fileName) + => GetScopedFilePath(repositoryName, "plans", adapterKind, fileName); + + public static string GetRuntimeFilePath(string repositoryName, string scopeName, string fileName) + => GetScopedFilePath(repositoryName, "runtime", scopeName, fileName); + + public static string ResolvePSPublishModulePath() + => PSPublishModuleLocator.ResolveModulePath(); + + internal static string GetStudioRootPath(string? localApplicationDataPath = null) + { + var root = string.IsNullOrWhiteSpace(localApplicationDataPath) + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : localApplicationDataPath; + + return Path.Combine(root, "PowerForgeStudio"); + } + + internal static string GetScopedFilePath( + string repositoryName, + string areaName, + string scopeName, + string fileName, + string? localApplicationDataPath = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName); + ArgumentException.ThrowIfNullOrWhiteSpace(areaName); + ArgumentException.ThrowIfNullOrWhiteSpace(scopeName); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + + var directory = Path.Combine( + GetStudioRootPath(localApplicationDataPath), + SanitizePathSegment(areaName), + SanitizePathSegment(repositoryName), + SanitizePathSegment(scopeName)); + + Directory.CreateDirectory(directory); + return Path.Combine(directory, fileName); + } + + internal static string SanitizePathSegment(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + var invalidCharacters = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(invalidCharacters.Contains(character) ? '_' : character); + } + + return builder.ToString(); + } +} diff --git a/PowerForgeStudio.Orchestrator/Portfolio/GitRemoteResolver.cs b/PowerForgeStudio.Orchestrator/Portfolio/GitRemoteResolver.cs index 6e15ddb4..6077fcdd 100644 --- a/PowerForgeStudio.Orchestrator/Portfolio/GitRemoteResolver.cs +++ b/PowerForgeStudio.Orchestrator/Portfolio/GitRemoteResolver.cs @@ -1,9 +1,21 @@ -using System.Diagnostics; +using PowerForge; namespace PowerForgeStudio.Orchestrator.Portfolio; internal sealed class GitRemoteResolver : IGitRemoteResolver { + private readonly Func> _getRemoteUrlAsync; + + public GitRemoteResolver() + : this((repositoryRoot, remoteName, cancellationToken) => new GitClient().GetRemoteUrlAsync(repositoryRoot, remoteName, cancellationToken)) + { + } + + internal GitRemoteResolver(Func> getRemoteUrlAsync) + { + _getRemoteUrlAsync = getRemoteUrlAsync; + } + public async Task ResolveOriginUrlAsync(string repositoryRoot, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(repositoryRoot) || !Directory.Exists(repositoryRoot)) @@ -13,26 +25,9 @@ internal sealed class GitRemoteResolver : IGitRemoteResolver try { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo { - FileName = "git", - WorkingDirectory = repositoryRoot, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - process.StartInfo.ArgumentList.Add("remote"); - process.StartInfo.ArgumentList.Add("get-url"); - process.StartInfo.ArgumentList.Add("origin"); - - process.Start(); - var standardOutput = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - var _ = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - - return process.ExitCode == 0 - ? standardOutput.Trim() + var result = await _getRemoteUrlAsync(repositoryRoot, "origin", cancellationToken).ConfigureAwait(false); + return result.Succeeded + ? result.StdOut.Trim() : null; } catch diff --git a/PowerForgeStudio.Orchestrator/Portfolio/PowerShellCommandRunner.cs b/PowerForgeStudio.Orchestrator/Portfolio/PowerShellCommandRunner.cs index bff46be0..8b2c5757 100644 --- a/PowerForgeStudio.Orchestrator/Portfolio/PowerShellCommandRunner.cs +++ b/PowerForgeStudio.Orchestrator/Portfolio/PowerShellCommandRunner.cs @@ -1,10 +1,21 @@ using System.Diagnostics; +using PowerForge; namespace PowerForgeStudio.Orchestrator.Portfolio; public sealed class PowerShellCommandRunner { - private static readonly Lazy DefaultExecutable = new(ResolveDefaultPowerShellExecutable, LazyThreadSafetyMode.ExecutionAndPublication); + private readonly IPowerShellRunner _powerShellRunner; + + public PowerShellCommandRunner() + : this(new PowerShellRunner()) + { + } + + internal PowerShellCommandRunner(IPowerShellRunner powerShellRunner) + { + _powerShellRunner = powerShellRunner; + } public async Task RunCommandAsync( string workingDirectory, @@ -23,128 +34,20 @@ public async Task RunCommandAsync( ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); ArgumentException.ThrowIfNullOrWhiteSpace(script); - var executable = ResolvePowerShellExecutable(); - using var process = new Process(); - process.StartInfo = new ProcessStartInfo { - FileName = executable, - WorkingDirectory = workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - process.StartInfo.ArgumentList.Add("-NoProfile"); - process.StartInfo.ArgumentList.Add("-NonInteractive"); - if (OperatingSystem.IsWindows()) - { - process.StartInfo.ArgumentList.Add("-ExecutionPolicy"); - process.StartInfo.ArgumentList.Add("Bypass"); - } - - process.StartInfo.ArgumentList.Add("-Command"); - process.StartInfo.ArgumentList.Add(script); - if (environmentVariables is not null) - { - foreach (var variable in environmentVariables) - { - if (variable.Value is null) - { - process.StartInfo.Environment.Remove(variable.Key); - continue; - } - - process.StartInfo.Environment[variable.Key] = variable.Value; - } - } - var startedAt = Stopwatch.StartNew(); - process.Start(); - - var stdOutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var stdErrTask = process.StandardError.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - + var result = await Task.Run(() => _powerShellRunner.Run(PowerShellRunRequest.ForCommand( + commandText: script, + timeout: TimeSpan.FromMinutes(15), + preferPwsh: !OperatingSystem.IsWindows(), + workingDirectory: workingDirectory, + environmentVariables: environmentVariables, + executableOverride: Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"))), cancellationToken).ConfigureAwait(false); startedAt.Stop(); + return new PowerShellExecutionResult( - process.ExitCode, + result.ExitCode, startedAt.Elapsed, - await stdOutTask.ConfigureAwait(false), - await stdErrTask.ConfigureAwait(false)); - } - - private static string ResolvePowerShellExecutable() - { - var configuredExecutable = Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"); - if (!string.IsNullOrWhiteSpace(configuredExecutable) && CanExecute(configuredExecutable)) - { - return configuredExecutable; - } - - return DefaultExecutable.Value; - } - - private static string ResolveDefaultPowerShellExecutable() - { - foreach (var candidate in GetCandidateExecutables()) - { - if (CanExecute(candidate)) - { - return candidate; - } - } - - return OperatingSystem.IsWindows() ? "powershell" : "pwsh"; - } - - private static IReadOnlyList GetCandidateExecutables() - { - return OperatingSystem.IsWindows() - ? ["powershell", "pwsh"] - : ["pwsh", "powershell"]; - } - - private static bool CanExecute(string executable) - { - try - { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo { - FileName = executable, - Arguments = "-NoProfile -NonInteractive -Command \"$PSVersionTable.PSVersion.ToString()\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - process.Start(); - if (!process.WaitForExit(3000)) - { - TryKill(process); - return false; - } - - return process.ExitCode == 0; - } - catch - { - return false; - } - } - - private static void TryKill(Process process) - { - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - process.WaitForExit(1000); - } - } - catch - { - // Swallow probe cleanup failures; the caller is only checking executable availability. - } + result.StdOut, + result.StdErr); } } diff --git a/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitQuickActionExecutionService.cs b/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitQuickActionExecutionService.cs index 56bb5f05..871e483f 100644 --- a/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitQuickActionExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitQuickActionExecutionService.cs @@ -1,11 +1,12 @@ using System.Diagnostics; +using PowerForge; using PowerForgeStudio.Domain.Portfolio; namespace PowerForgeStudio.Orchestrator.Portfolio; public sealed class RepositoryGitQuickActionExecutionService : IRepositoryGitQuickActionExecutionService { - private readonly Func> _runPowerShellAsync; + private readonly Func> _runGitAsync; public RepositoryGitQuickActionExecutionService() : this(null) @@ -13,11 +14,11 @@ public RepositoryGitQuickActionExecutionService() } internal RepositoryGitQuickActionExecutionService( - Func>? runPowerShellAsync) + Func>? runGitAsync) { - var runner = new PowerShellCommandRunner(); - _runPowerShellAsync = runPowerShellAsync - ?? ((workingDirectory, script, cancellationToken) => runner.RunCommandAsync(workingDirectory, script, cancellationToken)); + var gitClient = new GitClient(); + _runGitAsync = runGitAsync + ?? ((request, cancellationToken) => gitClient.RunAsync(request, cancellationToken)); } public async Task ExecuteAsync( @@ -42,18 +43,27 @@ public async Task ExecuteAsync( Summary: $"Opened {action.Title}."); } - var result = await _runPowerShellAsync(repositoryRoot, action.Payload, cancellationToken).ConfigureAwait(false); + var request = BuildRequest(repositoryRoot, action); + if (request is null) + { + return new RepositoryGitQuickActionExecutionResult( + Succeeded: false, + Summary: $"{action.Title} is not mapped to a supported reusable git operation.", + ErrorTail: action.Payload); + } + + var result = await _runGitAsync(request, cancellationToken).ConfigureAwait(false); return result.ExitCode == 0 ? new RepositoryGitQuickActionExecutionResult( Succeeded: true, Summary: $"{action.Title} completed successfully.", - OutputTail: Tail(result.StandardOutput), - ErrorTail: Tail(result.StandardError)) + OutputTail: Tail(result.StdOut), + ErrorTail: Tail(result.StdErr)) : new RepositoryGitQuickActionExecutionResult( Succeeded: false, Summary: $"{action.Title} failed with exit code {result.ExitCode}.", - OutputTail: Tail(result.StandardOutput), - ErrorTail: Tail(result.StandardError)); + OutputTail: Tail(result.StdOut), + ErrorTail: Tail(result.StdErr)); } catch (Exception exception) { @@ -75,4 +85,64 @@ public async Task ExecuteAsync( Environment.NewLine, value.Split(["\r\n", "\n"], StringSplitOptions.None).TakeLast(8)); } + + private static GitCommandRequest? BuildRequest(string repositoryRoot, RepositoryGitQuickAction action) + { + var gitOperation = action.GitOperation ?? InferOperation(action.Payload); + if (gitOperation is null) + return null; + + return gitOperation.Value switch + { + RepositoryGitOperationKind.StatusShortBranch => new GitCommandRequest(repositoryRoot, GitCommandKind.StatusShortBranch), + RepositoryGitOperationKind.StatusShort => new GitCommandRequest(repositoryRoot, GitCommandKind.StatusShort), + RepositoryGitOperationKind.ShowTopLevel => new GitCommandRequest(repositoryRoot, GitCommandKind.ShowTopLevel), + RepositoryGitOperationKind.PullRebase => new GitCommandRequest(repositoryRoot, GitCommandKind.PullRebase), + RepositoryGitOperationKind.CreateBranch => new GitCommandRequest( + repositoryRoot, + GitCommandKind.CreateBranch, + branchName: action.GitOperationArgument ?? ParseBranchName(action.Payload)), + RepositoryGitOperationKind.SetUpstream => new GitCommandRequest( + repositoryRoot, + GitCommandKind.SetUpstream, + branchName: action.GitOperationArgument ?? ParseSetUpstream(action.Payload).BranchName, + remoteName: ParseSetUpstream(action.Payload).RemoteName), + _ => null + }; + } + + private static RepositoryGitOperationKind? InferOperation(string payload) + { + if (string.Equals(payload, "git status --short --branch", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.StatusShortBranch; + if (string.Equals(payload, "git status --short", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.StatusShort; + if (string.Equals(payload, "git rev-parse --show-toplevel", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.ShowTopLevel; + if (string.Equals(payload, "git pull --rebase", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.PullRebase; + if (payload.StartsWith("git switch -c ", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.CreateBranch; + if (payload.StartsWith("git push --set-upstream ", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.SetUpstream; + + return null; + } + + private static string? ParseBranchName(string payload) + { + const string prefix = "git switch -c "; + return payload.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? payload[prefix.Length..].Trim() + : null; + } + + private static (string RemoteName, string? BranchName) ParseSetUpstream(string payload) + { + var parts = payload.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 5) + return (parts[3], parts[4]); + + return ("origin", null); + } } diff --git a/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitQuickActionService.cs b/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitQuickActionService.cs index 26d1623c..ccd443d3 100644 --- a/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitQuickActionService.cs +++ b/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitQuickActionService.cs @@ -67,7 +67,9 @@ public IReadOnlyList BuildActions( Kind: RepositoryGitQuickActionKind.GitCommand, Payload: step.CommandText, ExecuteLabel: "Run Here", - IsPrimary: step.IsPrimary)); + IsPrimary: step.IsPrimary, + GitOperation: step.GitOperation ?? InferGitOperation(step.CommandText), + GitOperationArgument: step.GitOperationArgument)); } return actions @@ -82,6 +84,24 @@ public IReadOnlyList BuildActions( .ToArray(); } + private static RepositoryGitOperationKind? InferGitOperation(string commandText) + { + if (string.Equals(commandText, "git status --short --branch", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.StatusShortBranch; + if (string.Equals(commandText, "git status --short", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.StatusShort; + if (string.Equals(commandText, "git rev-parse --show-toplevel", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.ShowTopLevel; + if (string.Equals(commandText, "git pull --rebase", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.PullRebase; + if (commandText.StartsWith("git switch -c ", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.CreateBranch; + if (commandText.StartsWith("git push --set-upstream ", StringComparison.OrdinalIgnoreCase)) + return RepositoryGitOperationKind.SetUpstream; + + return null; + } + private static bool CanExecuteDirectly(string commandText) { if (string.IsNullOrWhiteSpace(commandText)) diff --git a/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitRemediationService.cs b/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitRemediationService.cs index 397bee62..6686e5cf 100644 --- a/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitRemediationService.cs +++ b/PowerForgeStudio.Orchestrator/Portfolio/RepositoryGitRemediationService.cs @@ -13,7 +13,8 @@ public IReadOnlyList BuildSteps(RepositoryPortfoli new RepositoryGitRemediationStep( Title: "Select a repository", Summary: "Choose a managed repository to see git remediation guidance and exact command suggestions.", - CommandText: "git status --short --branch") + CommandText: "git status --short --branch", + GitOperation: RepositoryGitOperationKind.StatusShortBranch) ]; } @@ -28,7 +29,8 @@ public IReadOnlyList BuildSteps(RepositoryPortfoli Title: "Inspect current git state", Summary: "Start by confirming the branch, upstream, and local file status before changing anything.", CommandText: "git status --short --branch", - IsPrimary: repository.Git.GitDiagnostics.Count == 0)); + IsPrimary: repository.Git.GitDiagnostics.Count == 0, + GitOperation: RepositoryGitOperationKind.StatusShortBranch)); foreach (var diagnostic in repository.Git.GitDiagnostics) { @@ -38,7 +40,8 @@ public IReadOnlyList BuildSteps(RepositoryPortfoli steps.Add(new RepositoryGitRemediationStep( Title: "Verify repository root", Summary: "Open the expected repo/worktree folder and confirm git can see it before any release action runs.", - CommandText: "git rev-parse --show-toplevel")); + CommandText: "git rev-parse --show-toplevel", + GitOperation: RepositoryGitOperationKind.ShowTopLevel)); break; case RepositoryGitDiagnosticCode.DetachedHead: @@ -46,7 +49,9 @@ public IReadOnlyList BuildSteps(RepositoryPortfoli Title: "Create a working branch", Summary: "Detached HEAD should be turned into a named branch before build, tag, or publish work continues.", CommandText: $"git switch -c {suggestedBranch}", - IsPrimary: true)); + IsPrimary: true, + GitOperation: RepositoryGitOperationKind.CreateBranch, + GitOperationArgument: suggestedBranch)); break; case RepositoryGitDiagnosticCode.NoUpstream: @@ -65,7 +70,8 @@ public IReadOnlyList BuildSteps(RepositoryPortfoli Title: "Inspect local changes", Summary: "Review modified and untracked files before deciding whether they belong in this release flow.", CommandText: "git status --short", - IsPrimary: true)); + IsPrimary: true, + GitOperation: RepositoryGitOperationKind.StatusShort)); break; case RepositoryGitDiagnosticCode.BehindUpstream: @@ -73,7 +79,8 @@ public IReadOnlyList BuildSteps(RepositoryPortfoli Title: "Rebase onto upstream", Summary: "Sync with remote commits so the release run starts from the latest shared branch state.", CommandText: "git pull --rebase", - IsPrimary: true)); + IsPrimary: true, + GitOperation: RepositoryGitOperationKind.PullRebase)); break; case RepositoryGitDiagnosticCode.ProtectedBaseBranchFlow: @@ -81,13 +88,17 @@ public IReadOnlyList BuildSteps(RepositoryPortfoli Title: "Move work to a PR branch", Summary: "Protected base branches usually need a feature/worktree branch before you can push or open a PR.", CommandText: $"git switch -c {suggestedBranch}", - IsPrimary: repository.Git.AheadCount > 0)); + IsPrimary: repository.Git.AheadCount > 0, + GitOperation: RepositoryGitOperationKind.CreateBranch, + GitOperationArgument: suggestedBranch)); steps.Add(new RepositoryGitRemediationStep( Title: "Publish the PR branch", Summary: "Push the new branch with tracking so GitHub can open a PR and run required checks.", CommandText: $"git push --set-upstream origin {suggestedBranch}", - IsPrimary: repository.Git.AheadCount > 0)); + IsPrimary: repository.Git.AheadCount > 0, + GitOperation: RepositoryGitOperationKind.SetUpstream, + GitOperationArgument: suggestedBranch)); break; } } diff --git a/PowerForgeStudio.Orchestrator/Portfolio/RepositoryPlanPreviewService.cs b/PowerForgeStudio.Orchestrator/Portfolio/RepositoryPlanPreviewService.cs index 908efe36..eb79afb2 100644 --- a/PowerForgeStudio.Orchestrator/Portfolio/RepositoryPlanPreviewService.cs +++ b/PowerForgeStudio.Orchestrator/Portfolio/RepositoryPlanPreviewService.cs @@ -1,12 +1,30 @@ -using System.Text; +using PowerForge; using PowerForgeStudio.Domain.Portfolio; +using PowerForgeStudio.Orchestrator.Host; using PowerForgeStudio.Orchestrator.PowerShell; namespace PowerForgeStudio.Orchestrator.Portfolio; public sealed class RepositoryPlanPreviewService { - private readonly PowerShellCommandRunner _commandRunner = new(); + private readonly ProjectBuildHostService _projectBuildHostService; + private readonly ProjectBuildCommandHostService _projectBuildCommandHostService; + private readonly ModuleBuildHostService _moduleBuildHostService; + + public RepositoryPlanPreviewService() + : this(new ProjectBuildHostService(), new ProjectBuildCommandHostService(), new ModuleBuildHostService()) + { + } + + internal RepositoryPlanPreviewService( + ProjectBuildHostService projectBuildHostService, + ProjectBuildCommandHostService projectBuildCommandHostService, + ModuleBuildHostService moduleBuildHostService) + { + _projectBuildHostService = projectBuildHostService; + _projectBuildCommandHostService = projectBuildCommandHostService; + _moduleBuildHostService = moduleBuildHostService; + } public async Task> PopulatePlanPreviewAsync( IEnumerable items, @@ -65,31 +83,67 @@ private static int GetPreviewPriority(Domain.Catalog.ReleaseRepositoryKind repos private async Task RunModulePlanAsync(RepositoryPortfolioItem item, CancellationToken cancellationToken) { - var modulePath = ResolvePSPublishModulePath(); + var modulePath = PowerForgeStudioHostPaths.ResolvePSPublishModulePath(); var outputPath = BuildPlanOutputPath(item.Name, RepositoryPlanAdapterKind.ModuleJsonExport, "powerforge.json"); - var script = BuildModuleScript(item.Repository.RootPath, item.Repository.ModuleBuildScriptPath!, outputPath, modulePath); - var execution = await _commandRunner.RunCommandAsync(item.Repository.RootPath, script, cancellationToken); + var execution = await _moduleBuildHostService.ExportPipelineJsonAsync(new ModuleBuildHostExportRequest { + RepositoryRoot = item.Repository.RootPath, + ScriptPath = item.Repository.ModuleBuildScriptPath!, + ModulePath = modulePath, + OutputPath = outputPath + }, cancellationToken); + var success = execution.Succeeded && File.Exists(outputPath); - return BuildResult( - RepositoryPlanAdapterKind.ModuleJsonExport, - outputPath, - execution, - successSummary: "Module JSON config exported.", - failureSummary: "Module JSON export failed."); + return new RepositoryPlanResult( + AdapterKind: RepositoryPlanAdapterKind.ModuleJsonExport, + Status: success ? RepositoryPlanStatus.Succeeded : RepositoryPlanStatus.Failed, + Summary: success ? "Module JSON config exported." : "Module JSON export failed.", + PlanPath: success ? outputPath : null, + ExitCode: execution.ExitCode, + DurationSeconds: Math.Round(execution.Duration.TotalSeconds, 2), + OutputTail: TrimTail(execution.StandardOutput), + ErrorTail: TrimTail(execution.StandardError)); } private async Task RunProjectPlanAsync(RepositoryPortfolioItem item, CancellationToken cancellationToken) { - var modulePath = ResolvePSPublishModulePath(); var outputPath = BuildPlanOutputPath(item.Name, RepositoryPlanAdapterKind.ProjectPlan, "project.plan.json"); var configPath = ResolveProjectConfigPath(item.Repository.ProjectBuildScriptPath!, item.Repository.RootPath); - var script = BuildProjectScript(item.Repository.RootPath, outputPath, modulePath, configPath); - var execution = await _commandRunner.RunCommandAsync(item.Repository.RootPath, script, cancellationToken); + if (!string.IsNullOrWhiteSpace(configPath)) + { + var execution = _projectBuildHostService.Execute(new ProjectBuildHostRequest { + ConfigPath = configPath, + PlanOutputPath = outputPath, + ExecuteBuild = false, + PlanOnly = true, + UpdateVersions = false, + Build = false, + PublishNuget = false, + PublishGitHub = false + }); + + var success = execution.Success && File.Exists(outputPath); + return new RepositoryPlanResult( + AdapterKind: RepositoryPlanAdapterKind.ProjectPlan, + Status: success ? RepositoryPlanStatus.Succeeded : RepositoryPlanStatus.Failed, + Summary: success ? "Project build plan generated." : "Project build plan failed.", + PlanPath: success ? outputPath : null, + ExitCode: success ? 0 : 1, + DurationSeconds: Math.Round(execution.Duration.TotalSeconds, 2), + OutputTail: null, + ErrorTail: success ? null : execution.ErrorMessage); + } + + var powerShellExecution = await _projectBuildCommandHostService.GeneratePlanAsync(new ProjectBuildCommandPlanRequest { + RepositoryRoot = item.Repository.RootPath, + PlanOutputPath = outputPath, + ConfigPath = configPath, + ModulePath = PowerForgeStudioHostPaths.ResolvePSPublishModulePath() + }, cancellationToken); return BuildResult( RepositoryPlanAdapterKind.ProjectPlan, outputPath, - execution, + powerShellExecution, successSummary: "Project build plan generated.", failureSummary: "Project build plan failed."); } @@ -97,7 +151,7 @@ private async Task RunProjectPlanAsync(RepositoryPortfolio private static RepositoryPlanResult BuildResult( RepositoryPlanAdapterKind adapterKind, string outputPath, - PowerShellExecutionResult execution, + ProjectBuildCommandHostExecutionResult execution, string successSummary, string failureSummary) { @@ -115,15 +169,7 @@ private static RepositoryPlanResult BuildResult( private static string BuildPlanOutputPath(string repositoryName, RepositoryPlanAdapterKind adapterKind, string fileName) { - var root = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "PowerForgeStudio", - "plans", - SanitizePathSegment(repositoryName), - adapterKind.ToString()); - - Directory.CreateDirectory(root); - return Path.Combine(root, fileName); + return PowerForgeStudioHostPaths.GetPlansFilePath(repositoryName, adapterKind.ToString(), fileName); } internal static string? ResolveProjectConfigPath(string projectBuildScriptPath, string repositoryRoot) @@ -145,120 +191,6 @@ private static string BuildPlanOutputPath(string repositoryName, RepositoryPlanA return File.Exists(rootConfig) ? rootConfig : null; } - private static string BuildProjectScript(string repositoryRoot, string outputPath, string modulePath, string? configPath) - { - var lines = new List { - "$ErrorActionPreference = 'Stop'", - BuildModuleImportClause(modulePath), - $"Set-Location -LiteralPath {PowerShellScriptEscaping.QuoteLiteral(repositoryRoot)}" - }; - - var command = new StringBuilder("Invoke-ProjectBuild -Plan:$true -PlanPath "); - command.Append(PowerShellScriptEscaping.QuoteLiteral(outputPath)); - if (!string.IsNullOrWhiteSpace(configPath)) - { - command.Append(" -ConfigPath ").Append(PowerShellScriptEscaping.QuoteLiteral(configPath)); - } - - lines.Add(command.ToString()); - return string.Join(Environment.NewLine, lines); - } - - private static string BuildModuleScript(string repositoryRoot, string scriptPath, string outputPath, string modulePath) - { - var moduleRoot = Directory.GetParent(Path.GetDirectoryName(scriptPath)!)?.FullName ?? repositoryRoot; - return string.Join(Environment.NewLine, new[] { - "$ErrorActionPreference = 'Stop'", - $"Set-Location -LiteralPath {PowerShellScriptEscaping.QuoteLiteral(moduleRoot)}", - BuildModuleImportClause(modulePath), - $"$targetJson = {PowerShellScriptEscaping.QuoteLiteral(outputPath)}", - "function Invoke-ModuleBuild {", - " [CmdletBinding(PositionalBinding = $false)]", - " param(", - " [Parameter(Position = 0)][string]$ModuleName,", - " [Parameter(Position = 1)][scriptblock]$Settings,", - " [string]$Path,", - " [switch]$ExitCode,", - " [Parameter(ValueFromRemainingArguments = $true)][object[]]$RemainingArgs", - " )", - " if (-not $Settings -and $RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -is [scriptblock]) {", - " $Settings = [scriptblock]$RemainingArgs[0]", - " if ($RemainingArgs.Count -gt 1) {", - " $RemainingArgs = $RemainingArgs[1..($RemainingArgs.Count - 1)]", - " } else {", - " $RemainingArgs = @()", - " }", - " }", - " $cmd = Get-Command -Name Invoke-ModuleBuild -CommandType Cmdlet -Module PSPublishModule", - " $invokeArgs = @{ ModuleName = $ModuleName; JsonOnly = $true; JsonPath = $targetJson; NoInteractive = $true }", - " if ($null -ne $Settings) { $invokeArgs.Settings = $Settings }", - " if (-not [string]::IsNullOrWhiteSpace($Path)) { $invokeArgs.Path = $Path }", - " if ($ExitCode) { $invokeArgs.ExitCode = $true }", - " if ($RemainingArgs.Count -gt 0) {", - " & $cmd @invokeArgs @RemainingArgs", - " } else {", - " & $cmd @invokeArgs", - " }", - "}", - "function Build-Module {", - " [CmdletBinding(PositionalBinding = $false)]", - " param(", - " [Parameter(Position = 0)][string]$ModuleName,", - " [Parameter(Position = 1)][scriptblock]$Settings,", - " [string]$Path,", - " [switch]$ExitCode,", - " [Parameter(ValueFromRemainingArguments = $true)][object[]]$RemainingArgs", - " )", - " if (-not $Settings -and $RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -is [scriptblock]) {", - " $Settings = [scriptblock]$RemainingArgs[0]", - " if ($RemainingArgs.Count -gt 1) {", - " $RemainingArgs = $RemainingArgs[1..($RemainingArgs.Count - 1)]", - " } else {", - " $RemainingArgs = @()", - " }", - " }", - " $forwardArgs = @{ ModuleName = $ModuleName }", - " if ($null -ne $Settings) { $forwardArgs.Settings = $Settings }", - " if (-not [string]::IsNullOrWhiteSpace($Path)) { $forwardArgs.Path = $Path }", - " if ($ExitCode) { $forwardArgs.ExitCode = $true }", - " if ($RemainingArgs.Count -gt 0) {", - " Invoke-ModuleBuild @forwardArgs @RemainingArgs", - " } else {", - " Invoke-ModuleBuild @forwardArgs", - " }", - "}", - "Set-Alias -Name Invoke-ModuleBuilder -Value Invoke-ModuleBuild -Scope Local", - $". {PowerShellScriptEscaping.QuoteLiteral(scriptPath)}" - }); - } - - private static string BuildModuleImportClause(string modulePath) - { - if (File.Exists(modulePath)) - { - return $"try {{ Import-Module {PowerShellScriptEscaping.QuoteLiteral(modulePath)} -Force -ErrorAction Stop }} catch {{ Import-Module PSPublishModule -Force -ErrorAction Stop }}"; - } - - return "Import-Module PSPublishModule -Force -ErrorAction Stop"; - } - - private static string ResolvePSPublishModulePath() - { - return PSPublishModuleLocator.ResolveModulePath(); - } - - private static string SanitizePathSegment(string value) - { - var invalidCharacters = Path.GetInvalidFileNameChars(); - var builder = new StringBuilder(value.Length); - foreach (var character in value) - { - builder.Append(invalidCharacters.Contains(character) ? '_' : character); - } - - return builder.ToString(); - } - private static string? TrimTail(string text) { if (string.IsNullOrWhiteSpace(text)) diff --git a/PowerForgeStudio.Orchestrator/PowerForgeStudio.Orchestrator.csproj b/PowerForgeStudio.Orchestrator/PowerForgeStudio.Orchestrator.csproj index 7604d0cb..bfd2b0ab 100644 --- a/PowerForgeStudio.Orchestrator/PowerForgeStudio.Orchestrator.csproj +++ b/PowerForgeStudio.Orchestrator/PowerForgeStudio.Orchestrator.csproj @@ -11,6 +11,7 @@ + diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildCheckpointReader.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildCheckpointReader.cs index 456440e5..6b4dd3bd 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildCheckpointReader.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildCheckpointReader.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using PowerForgeStudio.Domain.Queue; using PowerForgeStudio.Domain.Signing; @@ -6,28 +5,12 @@ namespace PowerForgeStudio.Orchestrator.Queue; public sealed class ReleaseBuildCheckpointReader { - private static readonly JsonSerializerOptions SerializerOptions = new() { - PropertyNameCaseInsensitive = true - }; + private readonly ReleaseQueueCheckpointSerializer _checkpointSerializer = new(); public ReleaseBuildExecutionResult? TryReadBuildResult(ReleaseQueueItem item) { ArgumentNullException.ThrowIfNull(item); - - if (!string.Equals(item.CheckpointKey, "sign.waiting.usb", StringComparison.OrdinalIgnoreCase) - || string.IsNullOrWhiteSpace(item.CheckpointStateJson)) - { - return null; - } - - try - { - return JsonSerializer.Deserialize(item.CheckpointStateJson, SerializerOptions); - } - catch - { - return null; - } + return _checkpointSerializer.TryRead(item, "sign.waiting.usb"); } public IReadOnlyList BuildSigningManifest(IEnumerable queueItems) diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs index afe4f8ec..8b6a8828 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseBuildExecutionService.cs @@ -1,16 +1,34 @@ -using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; +using PowerForge; +using PowerForgeStudio.Orchestrator.Host; using PowerForgeStudio.Orchestrator.Catalog; using PowerForgeStudio.Orchestrator.Portfolio; -using PowerForgeStudio.Orchestrator.PowerShell; namespace PowerForgeStudio.Orchestrator.Queue; public sealed class ReleaseBuildExecutionService : IReleaseBuildExecutionService { - private readonly RepositoryCatalogScanner _catalogScanner = new(); - private readonly PowerShellCommandRunner _commandRunner = new(); + private readonly RepositoryCatalogScanner _catalogScanner; + private readonly ProjectBuildHostService _projectBuildHostService; + private readonly ProjectBuildCommandHostService _projectBuildCommandHostService; + private readonly ModuleBuildHostService _moduleBuildHostService; + + public ReleaseBuildExecutionService() + : this(new RepositoryCatalogScanner(), new ProjectBuildHostService(), new ProjectBuildCommandHostService(), new ModuleBuildHostService()) + { + } + + internal ReleaseBuildExecutionService( + RepositoryCatalogScanner catalogScanner, + ProjectBuildHostService projectBuildHostService, + ProjectBuildCommandHostService projectBuildCommandHostService, + ModuleBuildHostService moduleBuildHostService) + { + _catalogScanner = catalogScanner; + _projectBuildHostService = projectBuildHostService; + _projectBuildCommandHostService = projectBuildCommandHostService; + _moduleBuildHostService = moduleBuildHostService; + } public async Task ExecuteAsync(string repositoryRoot, CancellationToken cancellationToken = default) { @@ -40,57 +58,73 @@ public async Task ExecuteAsync(string repositoryRoo results.Add(await ExecuteModuleBuildAsync(repository, cancellationToken)); } - var succeeded = results.Count > 0 && results.All(result => result.Succeeded); - var summary = succeeded - ? $"Build completed for {results.Count} adapter(s) without publish/install side effects." - : FirstLine(results.FirstOrDefault(result => !result.Succeeded)?.ErrorTail - ?? results.FirstOrDefault(result => !result.Succeeded)?.OutputTail - ?? "Build execution failed."); - - return new ReleaseBuildExecutionResult( - RootPath: repositoryRoot, - Succeeded: succeeded, - Summary: summary, - DurationSeconds: Math.Round((DateTimeOffset.UtcNow - startedAt).TotalSeconds, 2), - AdapterResults: results); + return ReleaseQueueExecutionResultFactory.CreateBuildResult( + repositoryRoot, + DateTimeOffset.UtcNow - startedAt, + results); } private async Task ExecuteProjectBuildAsync(PowerForgeStudio.Domain.Catalog.RepositoryCatalogEntry repository, CancellationToken cancellationToken) { var scriptPath = repository.ProjectBuildScriptPath!; - var configPath = Path.Combine(Path.GetDirectoryName(scriptPath)!, "project.build.json"); - string? sanitizedConfigPath = null; + var configPath = RepositoryPlanPreviewService.ResolveProjectConfigPath(scriptPath, repository.RootPath); - if (File.Exists(configPath)) + if (!string.IsNullOrWhiteSpace(configPath)) { - sanitizedConfigPath = PrepareProjectRuntimeConfig(repository.Name, configPath); - } - - var script = BuildProjectScript(repository.RootPath, sanitizedConfigPath, ResolvePSPublishModulePath()); - var execution = await _commandRunner.RunCommandAsync(repository.RootPath, script, cancellationToken); - var artifactInfo = CollectProjectArtifacts(sanitizedConfigPath, configPath); - var succeeded = execution.ExitCode == 0; + var execution = _projectBuildHostService.Execute(new ProjectBuildHostRequest { + ConfigPath = configPath, + ExecuteBuild = true, + PlanOnly = false, + UpdateVersions = false, + Build = true, + PublishNuget = false, + PublishGitHub = false + }); + var artifactInfo = CollectProjectArtifacts(execution); + + return new ReleaseBuildAdapterResult( + AdapterKind: ReleaseBuildAdapterKind.ProjectBuild, + Succeeded: execution.Success, + Summary: execution.Success ? "Project build completed with publish disabled." : "Project build failed.", + ExitCode: execution.Success ? 0 : 1, + DurationSeconds: Math.Round(execution.Duration.TotalSeconds, 2), + ArtifactDirectories: artifactInfo.Directories, + ArtifactFiles: artifactInfo.Files, + OutputTail: null, + ErrorTail: TrimTail(execution.ErrorMessage ?? execution.Result.Release?.ErrorMessage)); + } + + var powerShellExecution = await _projectBuildCommandHostService.ExecuteBuildAsync(new ProjectBuildCommandBuildRequest { + RepositoryRoot = repository.RootPath, + ConfigPath = configPath, + ModulePath = PowerForgeStudioHostPaths.ResolvePSPublishModulePath() + }, cancellationToken); + var fallbackArtifactInfo = CollectProjectArtifacts(repository.RootPath); + var succeeded = powerShellExecution.Succeeded; return new ReleaseBuildAdapterResult( AdapterKind: ReleaseBuildAdapterKind.ProjectBuild, Succeeded: succeeded, Summary: succeeded ? "Project build completed with publish disabled." : "Project build failed.", - ExitCode: execution.ExitCode, - DurationSeconds: Math.Round(execution.Duration.TotalSeconds, 2), - ArtifactDirectories: artifactInfo.Directories, - ArtifactFiles: artifactInfo.Files, - OutputTail: TrimTail(execution.StandardOutput), - ErrorTail: TrimTail(execution.StandardError)); + ExitCode: powerShellExecution.ExitCode, + DurationSeconds: Math.Round(powerShellExecution.Duration.TotalSeconds, 2), + ArtifactDirectories: fallbackArtifactInfo.Directories, + ArtifactFiles: fallbackArtifactInfo.Files, + OutputTail: TrimTail(powerShellExecution.StandardOutput), + ErrorTail: TrimTail(powerShellExecution.StandardError)); } private async Task ExecuteModuleBuildAsync(PowerForgeStudio.Domain.Catalog.RepositoryCatalogEntry repository, CancellationToken cancellationToken) { var scriptPath = repository.ModuleBuildScriptPath!; - var modulePath = ResolvePSPublishModulePath(); - var script = BuildModuleScript(repository.RootPath, scriptPath, modulePath); - var execution = await _commandRunner.RunCommandAsync(repository.RootPath, script, cancellationToken); + var modulePath = PowerForgeStudioHostPaths.ResolvePSPublishModulePath(); + var execution = await _moduleBuildHostService.ExecuteBuildAsync(new ModuleBuildHostBuildRequest { + RepositoryRoot = repository.RootPath, + ScriptPath = scriptPath, + ModulePath = modulePath + }, cancellationToken); var artifactInfo = CollectModuleArtifacts(scriptPath); - var succeeded = execution.ExitCode == 0; + var succeeded = execution.Succeeded; return new ReleaseBuildAdapterResult( AdapterKind: ReleaseBuildAdapterKind.ModuleBuild, @@ -104,118 +138,26 @@ private async Task ExecuteModuleBuildAsync(PowerForge ErrorTail: TrimTail(execution.StandardError)); } - private static string BuildProjectScript(string repositoryRoot, string? configPath, string modulePath) - { - var parts = new List { - "$ErrorActionPreference = 'Stop'", - BuildModuleImportClause(modulePath), - $"Set-Location -LiteralPath {PowerShellScriptEscaping.QuoteLiteral(repositoryRoot)}" - }; - - var command = new StringBuilder(); - command.Append("Invoke-ProjectBuild -Build:$true -PublishNuget:$false -PublishGitHub:$false -UpdateVersions:$false"); - if (!string.IsNullOrWhiteSpace(configPath)) - { - command.Append(" -ConfigPath ").Append(PowerShellScriptEscaping.QuoteLiteral(configPath)); - } - - parts.Add(command.ToString()); - return string.Join(Environment.NewLine, parts); - } - - private static string BuildModuleScript(string repositoryRoot, string scriptPath, string modulePath) + private static ArtifactCollection CollectProjectArtifacts(ProjectBuildHostExecutionResult execution) { - var moduleRoot = Directory.GetParent(Path.GetDirectoryName(scriptPath)!)?.FullName ?? repositoryRoot; - return string.Join(Environment.NewLine, new[] { - "$ErrorActionPreference = 'Stop'", - $"Set-Location -LiteralPath {PowerShellScriptEscaping.QuoteLiteral(moduleRoot)}", - BuildModuleImportClause(modulePath), - "function New-ConfigurationBuild {", - " param([Parameter(ValueFromRemainingArguments = $true)][object[]]$RemainingArgs)", - " $cmd = Get-Command -Name New-ConfigurationBuild -Module PSPublishModule", - " if ($RemainingArgs.Count -eq 1 -and $RemainingArgs[0] -is [System.Collections.IDictionary]) {", - " $params = @{}", - " foreach ($key in $RemainingArgs[0].Keys) { $params[$key] = $RemainingArgs[0][$key] }", - " $params['SignModule'] = $false", - " $params['CertificateThumbprint'] = $null", - " & $cmd @params", - " return", - " }", - " & $cmd @RemainingArgs -SignModule:$false", - "}", - $". {PowerShellScriptEscaping.QuoteLiteral(scriptPath)}" - }); - } - - private static string PrepareProjectRuntimeConfig(string repositoryName, string originalConfigPath) - { - var json = JsonNode.Parse(File.ReadAllText(originalConfigPath))?.AsObject() - ?? throw new InvalidOperationException($"Unable to parse project config {originalConfigPath}."); - var configDirectory = Path.GetDirectoryName(originalConfigPath)!; - - NormalizeProjectPath(json, "RootPath", configDirectory); - NormalizeProjectPath(json, "OutputPath", configDirectory); - NormalizeProjectPath(json, "StagingPath", configDirectory); - NormalizeProjectPath(json, "PlanOutputPath", configDirectory); - - json["Build"] = true; - json["UpdateVersions"] = false; - json["PublishNuget"] = false; - json["PublishGitHub"] = false; - json["CertificateThumbprint"] = null; - json["CertificatePFXPath"] = null; - json["CertificatePFXBase64"] = null; - json["CertificatePFXPassword"] = null; - - var runtimeDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "PowerForgeStudio", - "runtime", - SanitizePathSegment(repositoryName), - "project"); - Directory.CreateDirectory(runtimeDirectory); - - var runtimeConfigPath = Path.Combine(runtimeDirectory, "project.build.runtime.json"); - File.WriteAllText(runtimeConfigPath, json.ToJsonString(new JsonSerializerOptions { - WriteIndented = true - })); - - return runtimeConfigPath; - } - - private static void NormalizeProjectPath(JsonObject json, string propertyName, string configDirectory) - { - if (json[propertyName] is not JsonValue value || !value.TryGetValue(out var propertyValue) || string.IsNullOrWhiteSpace(propertyValue)) - { - return; - } + var directories = new HashSet(StringComparer.OrdinalIgnoreCase); + var files = new HashSet(StringComparer.OrdinalIgnoreCase); - if (Path.IsPathRooted(propertyValue)) - { - return; - } + AddArtifactDirectory(execution.StagingPath, directories); + AddArtifactDirectory(execution.OutputPath, directories); + AddArtifactDirectory(execution.ReleaseZipOutputPath, directories); + AddArtifactDirectory(Path.Combine(execution.RootPath, "Artefacts", "ProjectBuild"), directories); - json[propertyName] = Path.GetFullPath(Path.Combine(configDirectory, propertyValue)); + AddReleaseArtifactFiles(execution.Result.Release, files); + CollectArtifactFiles(directories, files); + return new ArtifactCollection(directories.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList(), files.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList()); } - private static ArtifactCollection CollectProjectArtifacts(string? sanitizedConfigPath, string originalConfigPath) + private static ArtifactCollection CollectProjectArtifacts(string repositoryRoot) { var directories = new HashSet(StringComparer.OrdinalIgnoreCase); var files = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (!string.IsNullOrWhiteSpace(sanitizedConfigPath) && File.Exists(sanitizedConfigPath)) - { - var json = JsonNode.Parse(File.ReadAllText(sanitizedConfigPath))?.AsObject(); - AddArtifactDirectory(json?["StagingPath"], directories); - AddArtifactDirectory(json?["OutputPath"], directories); - } - - var defaultProjectBuild = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(originalConfigPath)!, "..", "Artefacts", "ProjectBuild")); - if (Directory.Exists(defaultProjectBuild)) - { - directories.Add(defaultProjectBuild); - } - + AddArtifactDirectory(Path.Combine(repositoryRoot, "Artefacts", "ProjectBuild"), directories); CollectArtifactFiles(directories, files); return new ArtifactCollection(directories.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList(), files.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList()); } @@ -246,9 +188,9 @@ private static ArtifactCollection CollectModuleArtifacts(string moduleBuildScrip return new ArtifactCollection(directories.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList(), files.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList()); } - private static void AddArtifactDirectory(JsonNode? node, ISet directories) + private static void AddArtifactDirectory(string? path, ISet directories) { - if (node is not JsonValue value || !value.TryGetValue(out var path) || string.IsNullOrWhiteSpace(path)) + if (string.IsNullOrWhiteSpace(path)) { return; } @@ -259,51 +201,39 @@ private static void AddArtifactDirectory(JsonNode? node, ISet directorie } } - private static void CollectArtifactFiles(IEnumerable directories, ISet files) + private static void AddReleaseArtifactFiles(DotNetRepositoryReleaseResult? release, ISet files) { - foreach (var directory in directories) + if (release is null) { - foreach (var extension in new[] { "*.nupkg", "*.snupkg", "*.zip", "*.psd1", "*.psm1", "*.dll" }) - { - foreach (var file in Directory.EnumerateFiles(directory, extension, SearchOption.AllDirectories).Take(50)) - { - files.Add(file); - } - } + return; } - } - private static string BuildModuleImportClause(string modulePath) - { - if (File.Exists(modulePath)) + foreach (var package in release.Projects.SelectMany(project => project.Packages).Where(File.Exists)) { - return $"try {{ Import-Module {PowerShellScriptEscaping.QuoteLiteral(modulePath)} -Force -ErrorAction Stop }} catch {{ Import-Module PSPublishModule -Force -ErrorAction Stop }}"; + files.Add(package); } - return "Import-Module PSPublishModule -Force -ErrorAction Stop"; - } - - private static string ResolvePSPublishModulePath() - { - return PSPublishModuleLocator.ResolveModulePath(); + foreach (var zip in release.Projects.Select(project => project.ReleaseZipPath).Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path!))) + { + files.Add(zip!); + } } - private static string SanitizePathSegment(string value) + private static void CollectArtifactFiles(IEnumerable directories, ISet files) { - var invalidCharacters = Path.GetInvalidFileNameChars(); - var builder = new StringBuilder(value.Length); - foreach (var character in value) + foreach (var directory in directories) { - builder.Append(invalidCharacters.Contains(character) ? '_' : character); + foreach (var extension in new[] { "*.nupkg", "*.snupkg", "*.zip", "*.psd1", "*.psm1", "*.dll" }) + { + foreach (var file in Directory.EnumerateFiles(directory, extension, SearchOption.AllDirectories).Take(50)) + { + files.Add(file); + } + } } - - return builder.ToString(); } - private static string FirstLine(string value) - => value.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? value; - - private static string? TrimTail(string text) + private static string? TrimTail(string? text) { if (string.IsNullOrWhiteSpace(text)) { diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.cs index acc045a3..128dd77f 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleasePublishExecutionService.cs @@ -1,10 +1,8 @@ -using System.Diagnostics; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; +using PowerForge; using PowerForgeStudio.Domain.Publish; using PowerForgeStudio.Domain.Queue; using PowerForgeStudio.Orchestrator.Catalog; +using PowerForgeStudio.Orchestrator.Host; using PowerForgeStudio.Orchestrator.Portfolio; using PowerForgeStudio.Orchestrator.PowerShell; @@ -12,75 +10,58 @@ namespace PowerForgeStudio.Orchestrator.Queue; public sealed partial class ReleasePublishExecutionService : IReleasePublishExecutionService { - private readonly RepositoryCatalogScanner _catalogScanner = new(); - private readonly PowerShellCommandRunner _commandRunner = new(); - private const string GitHubPublishTokenEnvironmentVariable = "POWERFORGESTUDIO_GITHUB_RELEASE_TOKEN"; - private const string NuGetPushResponseDirectoryName = "nuget-push"; - private static readonly JsonSerializerOptions JsonOptions = new() { - PropertyNameCaseInsensitive = true - }; + private readonly RepositoryCatalogScanner _catalogScanner; + private readonly ModuleBuildHostService _moduleBuildHostService; + private readonly ProjectBuildHostService _projectBuildHostService; + private readonly ProjectBuildCommandHostService _projectBuildCommandHostService; + private readonly ProjectBuildPublishHostService _projectBuildPublishHostService; + private readonly ReleaseQueueCheckpointSerializer _checkpointSerializer = new(); + private readonly ReleaseQueueTargetProjectionService _targetProjectionService = new(); + private readonly Func> _pushNuGetPackageAsync; + private readonly Func> _publishGitHubReleaseAsync; + private readonly Func> _publishRepositoryAsync; + + public ReleasePublishExecutionService() + : this( + new RepositoryCatalogScanner(), + new ModuleBuildHostService(), + new ProjectBuildHostService(), + new ProjectBuildCommandHostService(), + new ProjectBuildPublishHostService(), + (request, cancellationToken) => new DotNetNuGetClient().PushPackageAsync(request, cancellationToken), + (request, _) => Task.FromResult(new GitHubReleasePublisher(new NullLogger()).PublishRelease(request)), + (request, _) => Task.FromResult(new RepositoryPublisher(new NullLogger()).Publish(request))) + { + } + + internal ReleasePublishExecutionService( + RepositoryCatalogScanner catalogScanner, + ModuleBuildHostService moduleBuildHostService, + ProjectBuildHostService projectBuildHostService, + ProjectBuildCommandHostService projectBuildCommandHostService, + ProjectBuildPublishHostService projectBuildPublishHostService, + Func> pushNuGetPackageAsync, + Func>? publishGitHubReleaseAsync = null, + Func>? publishRepositoryAsync = null) + { + _catalogScanner = catalogScanner; + _moduleBuildHostService = moduleBuildHostService; + _projectBuildHostService = projectBuildHostService; + _projectBuildCommandHostService = projectBuildCommandHostService; + _projectBuildPublishHostService = projectBuildPublishHostService; + _pushNuGetPackageAsync = pushNuGetPackageAsync; + _publishGitHubReleaseAsync = publishGitHubReleaseAsync ?? ((request, _) => Task.FromResult(new GitHubReleasePublisher(new NullLogger()).PublishRelease(request))); + _publishRepositoryAsync = publishRepositoryAsync ?? ((request, _) => Task.FromResult(new RepositoryPublisher(new NullLogger()).Publish(request))); + } public IReadOnlyList BuildPendingTargets(IEnumerable queueItems) { - ArgumentNullException.ThrowIfNull(queueItems); - - var targets = new List(); - foreach (var item in queueItems.Where(candidate => candidate.Stage == ReleaseQueueStage.Publish && candidate.Status == ReleaseQueueItemStatus.ReadyToRun)) - { - var signingResult = TryDeserializeSigningResult(item); - if (signingResult is null) - { - continue; - } - - var receipts = signingResult.Receipts ?? []; - var grouped = receipts.GroupBy(receipt => receipt.AdapterKind, StringComparer.OrdinalIgnoreCase); - foreach (var group in grouped) - { - var adapterKind = group.Key; - var paths = group.Select(receipt => receipt.ArtifactPath).ToArray(); - if (paths.Any(path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))) - { - targets.Add(new ReleasePublishTarget( - RootPath: item.RootPath, - RepositoryName: item.RepositoryName, - AdapterKind: adapterKind, - TargetName: $"{group.Count(path => path.ArtifactPath.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))} NuGet package(s)", - TargetKind: "NuGet", - SourcePath: paths.FirstOrDefault(path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)), - Destination: "Configured NuGet feed")); - } - - if (paths.Any(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))) - { - targets.Add(new ReleasePublishTarget( - RootPath: item.RootPath, - RepositoryName: item.RepositoryName, - AdapterKind: adapterKind, - TargetName: $"{paths.Count(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))} GitHub asset(s)", - TargetKind: "GitHub", - SourcePath: paths.FirstOrDefault(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)), - Destination: "Configured GitHub release")); - } - - if (string.Equals(adapterKind, ReleaseBuildAdapterKind.ModuleBuild.ToString(), StringComparison.OrdinalIgnoreCase) && - group.Any(receipt => string.Equals(receipt.ArtifactKind, "Directory", StringComparison.OrdinalIgnoreCase))) - { - targets.Add(new ReleasePublishTarget( - RootPath: item.RootPath, - RepositoryName: item.RepositoryName, - AdapterKind: adapterKind, - TargetName: "Module package", - TargetKind: "PowerShellRepository", - SourcePath: group.First(receipt => string.Equals(receipt.ArtifactKind, "Directory", StringComparison.OrdinalIgnoreCase)).ArtifactPath, - Destination: "Configured PowerShell repository")); - } - } - } - - return targets - .DistinctBy(target => $"{target.RootPath}|{target.AdapterKind}|{target.TargetKind}|{target.SourcePath}", StringComparer.OrdinalIgnoreCase) - .ToList(); + return _targetProjectionService.BuildTargets( + queueItems, + ReleaseQueueStage.Publish, + TryDeserializeSigningResult, + static (item, signingResult) => ProjectPendingTargets(item, signingResult), + static target => $"{target.RootPath}|{target.AdapterKind}|{target.TargetKind}|{target.SourcePath}"); } public async Task ExecuteAsync(ReleaseQueueItem queueItem, CancellationToken cancellationToken = default) @@ -153,19 +134,7 @@ public async Task ExecuteAsync(ReleaseQueueItem q receipts.Add(FailedReceipt(queueItem.RootPath, queueItem.RepositoryName, "Publish", "Publish", null, "No publish-capable adapter execution was produced.")); } - var published = receipts.Count(receipt => receipt.Status == ReleasePublishReceiptStatus.Published); - var skipped = receipts.Count(receipt => receipt.Status == ReleasePublishReceiptStatus.Skipped); - var failed = receipts.Count(receipt => receipt.Status == ReleasePublishReceiptStatus.Failed); - var summary = failed > 0 - ? $"Publish completed with {published} published, {skipped} skipped, and {failed} failed target(s)." - : $"Publish completed with {published} published and {skipped} skipped target(s)."; - - return new ReleasePublishExecutionResult( - RootPath: queueItem.RootPath, - Succeeded: failed == 0, - Summary: summary, - SourceCheckpointStateJson: queueItem.CheckpointStateJson, - Receipts: receipts); + return ReleaseQueueExecutionResultFactory.CreatePublishResult(queueItem, receipts); } } @@ -173,80 +142,19 @@ public sealed partial class ReleasePublishExecutionService { private async Task<(bool Succeeded, string? ErrorMessage)> PublishNugetPackageAsync(string packagePath, string apiKey, string source, CancellationToken cancellationToken) { - var responseFilePath = await CreateNuGetPushResponseFileAsync(packagePath, apiKey, source, cancellationToken).ConfigureAwait(false); - using var process = new Process(); - try - { - process.StartInfo = new ProcessStartInfo { - FileName = "dotnet", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - process.StartInfo.ArgumentList.Add($"@{responseFilePath}"); - - process.Start(); - var stdOutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var stdErrTask = process.StandardError.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - var stdOut = await stdOutTask.ConfigureAwait(false); - var stdErr = await stdErrTask.ConfigureAwait(false); + var result = await _pushNuGetPackageAsync( + new DotNetNuGetPushRequest( + packagePath: packagePath, + apiKey: apiKey, + source: source, + skipDuplicate: true, + workingDirectory: Path.GetDirectoryName(packagePath)), + cancellationToken).ConfigureAwait(false); - if (process.ExitCode == 0) - { - return (true, null); - } + if (result.Succeeded) + return (true, null); - return (false, FirstLine(stdErr) ?? FirstLine(stdOut) ?? $"dotnet nuget push failed with exit code {process.ExitCode}."); - } - finally - { - TryDeleteFile(responseFilePath); - } - } - - private static async Task CreateNuGetPushResponseFileAsync(string packagePath, string apiKey, string source, CancellationToken cancellationToken) - { - var runtimeDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "PowerForgeStudio", - "runtime", - NuGetPushResponseDirectoryName); - Directory.CreateDirectory(runtimeDirectory); - - var responseFilePath = Path.Combine(runtimeDirectory, $"nuget-push-{Guid.NewGuid():N}.rsp"); - var content = string.Join(Environment.NewLine, new[] { - "nuget", - "push", - QuoteResponseFileValue(packagePath), - "--api-key", - QuoteResponseFileValue(apiKey), - "--source", - QuoteResponseFileValue(source), - "--skip-duplicate" - }); - - await File.WriteAllTextAsync(responseFilePath, content, cancellationToken).ConfigureAwait(false); - return responseFilePath; - } - - private static string QuoteResponseFileValue(string value) - => "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; - - private static void TryDeleteFile(string path) - { - try - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - catch - { - // Best-effort cleanup for temporary response files. - } + return (false, result.ErrorMessage); } private async Task PublishGitHubReleaseAsync( @@ -261,87 +169,96 @@ private async Task PublishGitHubReleaseAsync( bool isPreRelease, CancellationToken cancellationToken) { - var script = string.Join("; ", new[] { - "$ErrorActionPreference = 'Stop'", - BuildModuleImportClause(ResolvePSPublishModulePath()), - $"$gitHubToken = $env:{GitHubPublishTokenEnvironmentVariable}", - "if ([string]::IsNullOrWhiteSpace($gitHubToken)) { throw 'GitHub access token was not provided to the publish process.' }", - $"$result = Send-GitHubRelease -GitHubUsername {PowerShellScriptEscaping.QuoteLiteral(owner)} -GitHubRepositoryName {PowerShellScriptEscaping.QuoteLiteral(repo)} -GitHubAccessToken $gitHubToken -TagName {PowerShellScriptEscaping.QuoteLiteral(tag)} -ReleaseName {PowerShellScriptEscaping.QuoteLiteral(releaseName)} -AssetFilePaths @({string.Join(", ", assetPaths.Select(PowerShellScriptEscaping.QuoteLiteral))}) -GenerateReleaseNotes:${generateReleaseNotes.ToString().ToLowerInvariant()} -IsPreRelease:${isPreRelease.ToString().ToLowerInvariant()} -ReuseExistingReleaseOnConflict:$true", - "$result | ConvertTo-Json -Compress" - }); - - var execution = await _commandRunner.RunCommandAsync( - repositoryRoot, - script, - new Dictionary { - [GitHubPublishTokenEnvironmentVariable] = token - }, - cancellationToken); - if (execution.ExitCode != 0) + try { - return new GitHubReleaseExecutionResult(false, null, FirstLine(execution.StandardError) ?? FirstLine(execution.StandardOutput) ?? "GitHub publish failed."); - } + var result = await _publishGitHubReleaseAsync( + new GitHubReleasePublishRequest { + Owner = owner, + Repository = repo, + Token = token, + TagName = tag, + ReleaseName = releaseName, + GenerateReleaseNotes = generateReleaseNotes, + IsPreRelease = isPreRelease, + ReuseExistingReleaseOnConflict = true, + AssetFilePaths = assetPaths + }, + cancellationToken).ConfigureAwait(false); - var parsed = JsonSerializer.Deserialize(execution.StandardOutput.Trim(), JsonOptions); - if (parsed is null) + return new GitHubReleaseExecutionResult( + result.Succeeded, + result.HtmlUrl, + result.Succeeded ? null : "GitHub publish failed."); + } + catch (Exception ex) { - return new GitHubReleaseExecutionResult(false, null, "GitHub publish returned unexpected output."); + return new GitHubReleaseExecutionResult(false, null, FirstLine(ex.Message) ?? "GitHub publish failed."); } - - return new GitHubReleaseExecutionResult(parsed.Succeeded, parsed.ReleaseUrl, parsed.Succeeded ? null : parsed.ErrorMessage); } private static bool IsPublishEnabled() => string.Equals(Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_ENABLE_PUBLISH"), "true", StringComparison.OrdinalIgnoreCase); - private static ReleaseSigningExecutionResult? TryDeserializeSigningResult(ReleaseQueueItem queueItem) + private ReleaseSigningExecutionResult? TryDeserializeSigningResult(ReleaseQueueItem queueItem) + => _checkpointSerializer.TryDeserialize(queueItem.CheckpointStateJson); + + private static IEnumerable ProjectPendingTargets(ReleaseQueueItem item, ReleaseSigningExecutionResult signingResult) { - if (string.IsNullOrWhiteSpace(queueItem.CheckpointStateJson)) + var targets = new List(); + var receipts = signingResult.Receipts ?? []; + var grouped = receipts.GroupBy(receipt => receipt.AdapterKind, StringComparer.OrdinalIgnoreCase); + foreach (var group in grouped) { - return null; - } + var adapterKind = group.Key; + var paths = group.Select(receipt => receipt.ArtifactPath).ToArray(); + if (paths.Any(path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))) + { + targets.Add(new ReleasePublishTarget( + RootPath: item.RootPath, + RepositoryName: item.RepositoryName, + AdapterKind: adapterKind, + TargetName: $"{group.Count(path => path.ArtifactPath.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))} NuGet package(s)", + TargetKind: "NuGet", + SourcePath: paths.FirstOrDefault(path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)), + Destination: "Configured NuGet feed")); + } - try - { - return JsonSerializer.Deserialize(queueItem.CheckpointStateJson, JsonOptions); - } - catch - { - return null; + if (paths.Any(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))) + { + targets.Add(new ReleasePublishTarget( + RootPath: item.RootPath, + RepositoryName: item.RepositoryName, + AdapterKind: adapterKind, + TargetName: $"{paths.Count(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))} GitHub asset(s)", + TargetKind: "GitHub", + SourcePath: paths.FirstOrDefault(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)), + Destination: "Configured GitHub release")); + } + + if (string.Equals(adapterKind, ReleaseBuildAdapterKind.ModuleBuild.ToString(), StringComparison.OrdinalIgnoreCase) && + group.Any(receipt => string.Equals(receipt.ArtifactKind, "Directory", StringComparison.OrdinalIgnoreCase))) + { + targets.Add(new ReleasePublishTarget( + RootPath: item.RootPath, + RepositoryName: item.RepositoryName, + AdapterKind: adapterKind, + TargetName: "Module package", + TargetKind: "PowerShellRepository", + SourcePath: group.First(receipt => string.Equals(receipt.ArtifactKind, "Directory", StringComparison.OrdinalIgnoreCase)).ArtifactPath, + Destination: "Configured PowerShell repository")); + } } + + return targets; } private static ReleasePublishReceipt FailedReceipt(string rootPath, string repositoryName, string adapterKind, string targetKind, string? destination, string summary) - { - return new ReleasePublishReceipt( - RootPath: rootPath, - RepositoryName: repositoryName, - AdapterKind: adapterKind, - TargetName: targetKind, - TargetKind: targetKind, - Destination: destination, - SourcePath: null, - Status: ReleasePublishReceiptStatus.Failed, - Summary: summary, - PublishedAtUtc: DateTimeOffset.UtcNow); - } + => ReleaseQueueReceiptFactory.FailedPublishReceipt(rootPath, repositoryName, adapterKind, targetKind, destination, summary); private static ReleasePublishReceipt SkippedReceipt(string rootPath, string repositoryName, string adapterKind, string targetKind, string? destination, string summary) - { - return new ReleasePublishReceipt( - RootPath: rootPath, - RepositoryName: repositoryName, - AdapterKind: adapterKind, - TargetName: targetKind, - TargetKind: targetKind, - Destination: destination, - SourcePath: null, - Status: ReleasePublishReceiptStatus.Skipped, - Summary: summary, - PublishedAtUtc: DateTimeOffset.UtcNow); - } + => ReleaseQueueReceiptFactory.SkippedPublishReceipt(rootPath, repositoryName, adapterKind, targetKind, destination, summary); - private static string ResolveModuleRepositoryName(ModulePublishConfig publishConfig) + private static string ResolveModuleRepositoryName(PublishConfiguration publishConfig) => publishConfig.Repository?.Name ?? publishConfig.RepositoryName ?? "PSGallery"; @@ -368,368 +285,84 @@ private static string ResolveModuleRepositoryName(ModulePublishConfig publishCon ?? zipAssets[0]; } - private static string? ResolveGitHubBaseVersion(ProjectPublishConfig config, ProjectReleasePlan release) - { - if (!string.IsNullOrWhiteSpace(config.GitHubPrimaryProject)) - { - var match = release.Projects.FirstOrDefault(project => string.Equals(project.ProjectName, config.GitHubPrimaryProject, StringComparison.OrdinalIgnoreCase)); - if (match is not null) - { - return match.NewVersion ?? match.OldVersion; - } - } - - var versions = release.Projects - .Where(project => project.IsPackable && !string.IsNullOrWhiteSpace(project.NewVersion)) - .Select(project => project.NewVersion!) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - return versions.Length == 1 ? versions[0] : null; - } - - private static ProjectPublishConfig LoadProjectPublishConfig(string configPath) - => JsonSerializer.Deserialize(File.ReadAllText(configPath), JsonOptions) ?? new ProjectPublishConfig(); - - private static string? ResolveSecret(string? inline, string? filePath, string? envName, string basePath) - { - if (!string.IsNullOrWhiteSpace(filePath)) - { - var fullPath = Path.IsPathRooted(filePath) - ? filePath - : Path.GetFullPath(Path.Combine(basePath, filePath)); - if (File.Exists(fullPath)) - { - return File.ReadAllText(fullPath).Trim(); - } - } - - if (!string.IsNullOrWhiteSpace(envName)) - { - var value = Environment.GetEnvironmentVariable(envName); - if (!string.IsNullOrWhiteSpace(value)) - { - return value.Trim(); - } - } - - return string.IsNullOrWhiteSpace(inline) ? null : inline.Trim(); - } - - private static string BuildProjectPlanScript(string repositoryRoot, string planPath, string? configPath, string modulePath) - { - var command = new StringBuilder(); - command.Append("Invoke-ProjectBuild -Plan:$true -PlanPath ").Append(PowerShellScriptEscaping.QuoteLiteral(planPath)); - if (!string.IsNullOrWhiteSpace(configPath)) - { - command.Append(" -ConfigPath ").Append(PowerShellScriptEscaping.QuoteLiteral(configPath)); - } - - return string.Join(Environment.NewLine, new[] { - "$ErrorActionPreference = 'Stop'", - BuildModuleImportClause(modulePath), - $"Set-Location -LiteralPath {PowerShellScriptEscaping.QuoteLiteral(repositoryRoot)}", - command.ToString() - }); - } - - private static string BuildModuleExportScript(string repositoryRoot, string scriptPath, string outputPath, string modulePath) - { - var moduleRoot = Directory.GetParent(Path.GetDirectoryName(scriptPath)!)?.FullName ?? repositoryRoot; - return string.Join(Environment.NewLine, new[] { - "$ErrorActionPreference = 'Stop'", - $"Set-Location -LiteralPath {PowerShellScriptEscaping.QuoteLiteral(moduleRoot)}", - BuildModuleImportClause(modulePath), - $"$targetJson = {PowerShellScriptEscaping.QuoteLiteral(outputPath)}", - "function Invoke-ModuleBuild {", - " [CmdletBinding(PositionalBinding = $false)]", - " param(", - " [Parameter(Position = 0)][string]$ModuleName,", - " [Parameter(Position = 1)][scriptblock]$Settings,", - " [string]$Path,", - " [switch]$ExitCode,", - " [Parameter(ValueFromRemainingArguments = $true)][object[]]$RemainingArgs", - " )", - " if (-not $Settings -and $RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -is [scriptblock]) {", - " $Settings = [scriptblock]$RemainingArgs[0]", - " if ($RemainingArgs.Count -gt 1) { $RemainingArgs = $RemainingArgs[1..($RemainingArgs.Count - 1)] } else { $RemainingArgs = @() }", - " }", - " $cmd = Get-Command -Name Invoke-ModuleBuild -CommandType Cmdlet -Module PSPublishModule", - " $invokeArgs = @{ ModuleName = $ModuleName; JsonOnly = $true; JsonPath = $targetJson; NoInteractive = $true }", - " if ($null -ne $Settings) { $invokeArgs.Settings = $Settings }", - " if (-not [string]::IsNullOrWhiteSpace($Path)) { $invokeArgs.Path = $Path }", - " if ($ExitCode) { $invokeArgs.ExitCode = $true }", - " if ($RemainingArgs.Count -gt 0) {", - " & $cmd @invokeArgs @RemainingArgs", - " } else {", - " & $cmd @invokeArgs", - " }", - "}", - "function Build-Module {", - " [CmdletBinding(PositionalBinding = $false)]", - " param(", - " [Parameter(Position = 0)][string]$ModuleName,", - " [Parameter(Position = 1)][scriptblock]$Settings,", - " [string]$Path,", - " [switch]$ExitCode,", - " [Parameter(ValueFromRemainingArguments = $true)][object[]]$RemainingArgs", - " )", - " if (-not $Settings -and $RemainingArgs.Count -gt 0 -and $RemainingArgs[0] -is [scriptblock]) {", - " $Settings = [scriptblock]$RemainingArgs[0]", - " if ($RemainingArgs.Count -gt 1) { $RemainingArgs = $RemainingArgs[1..($RemainingArgs.Count - 1)] } else { $RemainingArgs = @() }", - " }", - " $forwardArgs = @{ ModuleName = $ModuleName }", - " if ($null -ne $Settings) { $forwardArgs.Settings = $Settings }", - " if (-not [string]::IsNullOrWhiteSpace($Path)) { $forwardArgs.Path = $Path }", - " if ($ExitCode) { $forwardArgs.ExitCode = $true }", - " if ($RemainingArgs.Count -gt 0) {", - " Invoke-ModuleBuild @forwardArgs @RemainingArgs", - " } else {", - " Invoke-ModuleBuild @forwardArgs", - " }", - "}", - "Set-Alias -Name Invoke-ModuleBuilder -Value Invoke-ModuleBuild -Scope Local", - $". {PowerShellScriptEscaping.QuoteLiteral(scriptPath)}" - }); - } - - private static string BuildModuleRepositoryPublishScript(string packagePath, string repositoryName, string apiKey, string tool) - { - var publishPsResource = string.Join("; ", new[] { - "$cmd = Get-Command -Name Publish-PSResource -ErrorAction SilentlyContinue", - "if ($null -ne $cmd) { Publish-PSResource -Path " + PowerShellScriptEscaping.QuoteLiteral(packagePath) + " -Repository " + PowerShellScriptEscaping.QuoteLiteral(repositoryName) + " -ApiKey " + PowerShellScriptEscaping.QuoteLiteral(apiKey) + " -SkipDependencyCheck -ErrorAction Stop | Out-Null; exit 0 }" - }); - - var publishPowerShellGet = $"Publish-Module -Path {PowerShellScriptEscaping.QuoteLiteral(packagePath)} -Repository {PowerShellScriptEscaping.QuoteLiteral(repositoryName)} -NuGetApiKey {PowerShellScriptEscaping.QuoteLiteral(apiKey)} -ErrorAction Stop | Out-Null"; - return string.Join("; ", new[] { - "$ErrorActionPreference = 'Stop'", - tool.Equals("PowerShellGet", StringComparison.OrdinalIgnoreCase) - ? publishPowerShellGet - : $"{publishPsResource}; {publishPowerShellGet}" - }); - } - - private static string ApplyProjectTemplate(string template, string project, string version, string primaryProject, string primaryVersion, string repo, string date, string utcDate, string dateTime, string utcDateTime, string timestamp, string utcTimestamp) - => template - .Replace("{Project}", project ?? string.Empty) - .Replace("{Version}", version ?? string.Empty) - .Replace("{PrimaryProject}", primaryProject ?? string.Empty) - .Replace("{PrimaryVersion}", primaryVersion ?? string.Empty) - .Replace("{Repo}", repo ?? string.Empty) - .Replace("{Repository}", repo ?? string.Empty) - .Replace("{Date}", date ?? string.Empty) - .Replace("{UtcDate}", utcDate ?? string.Empty) - .Replace("{DateTime}", dateTime ?? string.Empty) - .Replace("{UtcDateTime}", utcDateTime ?? string.Empty) - .Replace("{Timestamp}", timestamp ?? string.Empty) - .Replace("{UtcTimestamp}", utcTimestamp ?? string.Empty); - - private static string ReplaceModuleTokens(string template, string moduleName, string resolvedVersion, string? preRelease) - { - var versionWithPreRelease = string.IsNullOrWhiteSpace(preRelease) ? resolvedVersion : $"{resolvedVersion}-{preRelease}"; - return template - .Replace("", moduleName) - .Replace("{ModuleName}", moduleName) - .Replace("", resolvedVersion) - .Replace("{ModuleVersion}", resolvedVersion) - .Replace("", versionWithPreRelease) - .Replace("{ModuleVersionWithPreRelease}", versionWithPreRelease) - .Replace("", $"v{versionWithPreRelease}") - .Replace("{TagModuleVersionWithPreRelease}", $"v{versionWithPreRelease}"); - } - - private static string BuildModuleImportClause(string modulePath) - => File.Exists(modulePath) - ? $"try {{ Import-Module {PowerShellScriptEscaping.QuoteLiteral(modulePath)} -Force -ErrorAction Stop }} catch {{ Import-Module PSPublishModule -Force -ErrorAction Stop }}" - : "Import-Module PSPublishModule -Force -ErrorAction Stop"; - - private static string ResolvePSPublishModulePath() - { - return PSPublishModuleLocator.ResolveModulePath(); - } - - private static string SanitizePathSegment(string value) - { - var invalidCharacters = Path.GetInvalidFileNameChars(); - var builder = new StringBuilder(value.Length); - foreach (var character in value) - { - builder.Append(invalidCharacters.Contains(character) ? '_' : character); - } - - return builder.ToString(); - } - private static string? FirstLine(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); - private sealed class ProjectPublishConfig - { - public bool? PublishNuget { get; set; } - public bool? PublishGitHub { get; set; } - public string? PublishSource { get; set; } - public string? PublishApiKey { get; set; } - public string? PublishApiKeyFilePath { get; set; } - public string? PublishApiKeyEnvName { get; set; } - public string? GitHubAccessToken { get; set; } - public string? GitHubAccessTokenFilePath { get; set; } - public string? GitHubAccessTokenEnvName { get; set; } - public string? GitHubUsername { get; set; } - public string? GitHubRepositoryName { get; set; } - public bool GitHubIsPreRelease { get; set; } - public bool GitHubIncludeProjectNameInTag { get; set; } = true; - public bool GitHubGenerateReleaseNotes { get; set; } - public string? GitHubReleaseName { get; set; } - public string? GitHubTagName { get; set; } - public string? GitHubTagTemplate { get; set; } - public string? GitHubReleaseMode { get; set; } - public string? GitHubPrimaryProject { get; set; } - } - - private sealed class ProjectReleasePlan - { - public bool Success { get; set; } - public string? ErrorMessage { get; set; } - public List Projects { get; set; } = []; - } - - private sealed class ProjectReleaseProject - { - public string ProjectName { get; set; } = string.Empty; - public bool IsPackable { get; set; } - public string? OldVersion { get; set; } - public string? NewVersion { get; set; } - public string? ReleaseZipPath { get; set; } - } - - private sealed record ModulePublishConfig( - string Destination, - string Tool, - string? ApiKey, - bool Enabled, - string? UserName, - string? RepositoryName, - string? OverwriteTagName, - bool DoNotMarkAsPreRelease, - bool GenerateReleaseNotes, - ModulePublishRepositoryConfig? Repository); - - private sealed class ModulePublishRepositoryConfig - { - public string? Name { get; set; } - public string? Uri { get; set; } - public string? SourceUri { get; set; } - public string? PublishUri { get; set; } - public ModulePublishRepositoryCredential? Credential { get; set; } - } - - private sealed class ModulePublishRepositoryCredential - { - public string? UserName { get; set; } - public string? Secret { get; set; } - } - private sealed record ModulePackageDetails(string ModuleName, string Version, string? PreRelease, string PackagePath, IReadOnlyList ZipAssets); private sealed record ModuleManifestInfo(string ModuleName, string Version, string? PreRelease); - private sealed class ModuleManifestJson - { - public string? ModuleName { get; set; } - public string? ModuleVersion { get; set; } - public string? PreRelease { get; set; } - } - - private sealed class GitHubReleaseJson - { - public bool Succeeded { get; set; } - public string? ReleaseUrl { get; set; } - public string? ErrorMessage { get; set; } - } - private sealed record GitHubReleaseExecutionResult(bool Succeeded, string? ReleaseUrl, string? ErrorMessage); } public sealed partial class ReleasePublishExecutionService { - private async Task GenerateProjectPlanAsync(PowerForgeStudio.Domain.Catalog.RepositoryCatalogEntry repository, CancellationToken cancellationToken) + private async Task GenerateProjectPlanAsync(PowerForgeStudio.Domain.Catalog.RepositoryCatalogEntry repository, CancellationToken cancellationToken) { var scriptPath = repository.ProjectBuildScriptPath!; - var configPath = Path.Combine(Path.GetDirectoryName(scriptPath)!, "project.build.json"); - var runtimeDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "PowerForgeStudio", - "runtime", - SanitizePathSegment(repository.Name), - "project-publish"); - Directory.CreateDirectory(runtimeDirectory); - - var planPath = Path.Combine(runtimeDirectory, "project.publish.plan.json"); - var script = BuildProjectPlanScript(repository.RootPath, planPath, File.Exists(configPath) ? configPath : null, ResolvePSPublishModulePath()); - var execution = await _commandRunner.RunCommandAsync(repository.RootPath, script, cancellationToken); - if (execution.ExitCode != 0 || !File.Exists(planPath)) + var configPath = RepositoryPlanPreviewService.ResolveProjectConfigPath(scriptPath, repository.RootPath); + var planPath = PowerForgeStudioHostPaths.GetRuntimeFilePath(repository.Name, "project-publish", "project.publish.plan.json"); + if (!string.IsNullOrWhiteSpace(configPath)) { - return null; + var execution = _projectBuildHostService.Execute(new ProjectBuildHostRequest { + ConfigPath = configPath, + PlanOutputPath = planPath, + ExecuteBuild = false, + PlanOnly = true, + UpdateVersions = false, + Build = false, + PublishNuget = false, + PublishGitHub = false + }); + + if (!execution.Success || !File.Exists(planPath)) + { + return null; + } + + return execution.Result.Release; + } + else + { + var execution = await _projectBuildCommandHostService.GeneratePlanAsync(new ProjectBuildCommandPlanRequest { + RepositoryRoot = repository.RootPath, + PlanOutputPath = planPath, + ConfigPath = configPath, + ModulePath = PowerForgeStudioHostPaths.ResolvePSPublishModulePath() + }, cancellationToken); + if (!execution.Succeeded || !File.Exists(planPath)) + { + return null; + } } - return JsonSerializer.Deserialize(await File.ReadAllTextAsync(planPath, cancellationToken), JsonOptions); + return await ReadProjectPlanFileAsync(planPath, cancellationToken); } - private async Task> ExportModulePublishConfigsAsync(string repositoryRoot, string scriptPath, CancellationToken cancellationToken) + private async Task> ExportModulePublishConfigsAsync(string repositoryRoot, string scriptPath, CancellationToken cancellationToken) { - var runtimeDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "PowerForgeStudio", - "runtime", - SanitizePathSegment(Path.GetFileName(repositoryRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))), - "module-publish"); - Directory.CreateDirectory(runtimeDirectory); - - var exportPath = Path.Combine(runtimeDirectory, "powerforge.publish.json"); - var script = BuildModuleExportScript(repositoryRoot, scriptPath, exportPath, ResolvePSPublishModulePath()); - var execution = await _commandRunner.RunCommandAsync(repositoryRoot, script, cancellationToken); + var repositoryName = Path.GetFileName(repositoryRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var exportPath = PowerForgeStudioHostPaths.GetRuntimeFilePath(repositoryName, "module-publish", "powerforge.publish.json"); + var execution = await _moduleBuildHostService.ExportPipelineJsonAsync(new ModuleBuildHostExportRequest { + RepositoryRoot = repositoryRoot, + ScriptPath = scriptPath, + ModulePath = PowerForgeStudioHostPaths.ResolvePSPublishModulePath(), + OutputPath = exportPath + }, cancellationToken); if (execution.ExitCode != 0 || !File.Exists(exportPath)) { return []; } - var root = JsonNode.Parse(await File.ReadAllTextAsync(exportPath, cancellationToken))?.AsObject(); - var segments = root?["Segments"]?.AsArray(); - if (segments is null) + try { - return []; + return new ModulePublishConfigurationReader().Read(exportPath); } - - var results = new List(); - foreach (var segment in segments) + catch { - var type = segment?["Type"]?.GetValue(); - if (!string.Equals(type, "GalleryNuget", StringComparison.OrdinalIgnoreCase) && - !string.Equals(type, "GitHubNuget", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (segment?["Configuration"] is not JsonObject configuration) - { - continue; - } - - results.Add(new ModulePublishConfig( - Destination: configuration["Destination"]?.GetValue() ?? (string.Equals(type, "GitHubNuget", StringComparison.OrdinalIgnoreCase) ? "GitHub" : "PowerShellGallery"), - Tool: configuration["Tool"]?.GetValue() ?? "Auto", - ApiKey: configuration["ApiKey"]?.GetValue(), - Enabled: configuration["Enabled"]?.GetValue() == true, - UserName: configuration["UserName"]?.GetValue(), - RepositoryName: configuration["RepositoryName"]?.GetValue(), - OverwriteTagName: configuration["OverwriteTagName"]?.GetValue(), - DoNotMarkAsPreRelease: configuration["DoNotMarkAsPreRelease"]?.GetValue() == true, - GenerateReleaseNotes: configuration["GenerateReleaseNotes"]?.GetValue() == true, - Repository: configuration["Repository"]?.Deserialize(JsonOptions))); + return []; } - - return results; } private async Task ResolveModulePackageDetailsAsync( @@ -789,30 +422,75 @@ private async Task> ExportModulePublishConfig private async Task ReadModuleManifestAsync(string repositoryRoot, string manifestPath, CancellationToken cancellationToken) { - var script = string.Join("; ", new[] { - "$ErrorActionPreference = 'Stop'", - $"$manifest = Import-PowerShellDataFile -Path {PowerShellScriptEscaping.QuoteLiteral(manifestPath)}", - "$preRelease = $null", - "if ($manifest.ContainsKey('PrivateData') -and $manifest.PrivateData -and $manifest.PrivateData.PSData) { $preRelease = $manifest.PrivateData.PSData.Prerelease }", - "@{ ModuleName = $manifest.RootModule; ModuleVersion = $manifest.ModuleVersion.ToString(); PreRelease = $preRelease } | ConvertTo-Json -Compress" - }); - - var execution = await _commandRunner.RunCommandAsync(repositoryRoot, script, cancellationToken); - if (execution.ExitCode != 0) + await Task.CompletedTask.ConfigureAwait(false); + try + { + var metadata = new ModuleManifestMetadataReader().Read(manifestPath); + return new ModuleManifestInfo(metadata.ModuleName, metadata.ModuleVersion, metadata.PreRelease); + } + catch { return null; } + } + + private static IReadOnlyList ResolveProjectGitHubAssets(DotNetRepositoryReleaseResult plan, ReleaseSigningExecutionResult signingResult, string? projectName = null) + { + var assets = plan.Projects + .Where(project => project.IsPackable && (string.IsNullOrWhiteSpace(projectName) || string.Equals(project.ProjectName, projectName, StringComparison.OrdinalIgnoreCase))) + .Select(project => !string.IsNullOrWhiteSpace(project.ReleaseZipPath) && File.Exists(project.ReleaseZipPath) + ? project.ReleaseZipPath! + : FindZipAsset(signingResult, project.ProjectName)) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => path!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); - var manifest = JsonSerializer.Deserialize(execution.StandardOutput.Trim(), JsonOptions); - if (manifest is null) + return assets; + } + + private async Task ReadProjectPlanFileAsync(string planPath, CancellationToken cancellationToken) + { + var plan = _checkpointSerializer.TryDeserialize( + await File.ReadAllTextAsync(planPath, cancellationToken).ConfigureAwait(false)); + if (plan is null) { return null; } - var moduleName = string.IsNullOrWhiteSpace(manifest.ModuleName) - ? Path.GetFileNameWithoutExtension(manifestPath) - : Path.GetFileNameWithoutExtension(manifest.ModuleName); - return new ModuleManifestInfo(moduleName, manifest.ModuleVersion ?? "0.0.0", manifest.PreRelease); + var result = new DotNetRepositoryReleaseResult { + Success = plan.Success, + ErrorMessage = plan.ErrorMessage + }; + + foreach (var project in plan.Projects) + { + result.Projects.Add(new DotNetRepositoryProjectResult { + ProjectName = project.ProjectName ?? string.Empty, + IsPackable = project.IsPackable, + OldVersion = project.OldVersion, + NewVersion = project.NewVersion, + ReleaseZipPath = project.ReleaseZipPath + }); + } + + return result; + } + + private sealed class ProjectReleasePlanFile + { + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + public List Projects { get; set; } = []; + } + + private sealed class ProjectReleaseProjectFile + { + public string? ProjectName { get; set; } + public bool IsPackable { get; set; } + public string? OldVersion { get; set; } + public string? NewVersion { get; set; } + public string? ReleaseZipPath { get; set; } } } @@ -833,7 +511,7 @@ private async Task> ExecuteModulePublishAsy var receipts = new List(); foreach (var publishConfig in publishConfigs.Where(config => config.Enabled)) { - if (string.Equals(publishConfig.Destination, "GitHub", StringComparison.OrdinalIgnoreCase)) + if (publishConfig.Destination == PublishDestination.GitHub) { receipts.Add(await ExecuteModuleGitHubPublishAsync(repository, publishConfig, packageDetails, cancellationToken)); continue; @@ -847,7 +525,7 @@ private async Task> ExecuteModulePublishAsy private async Task ExecuteModuleRepositoryPublishAsync( PowerForgeStudio.Domain.Catalog.RepositoryCatalogEntry repository, - ModulePublishConfig publishConfig, + PublishConfiguration publishConfig, ModulePackageDetails? packageDetails, CancellationToken cancellationToken) { @@ -857,40 +535,54 @@ private async Task ExecuteModuleRepositoryPublishAsync( return FailedReceipt(repository.RootPath, repository.Name, ReleaseBuildAdapterKind.ModuleBuild.ToString(), "Module publish", destination, "No publishable module package path was captured from the build artefacts."); } - if (publishConfig.Repository is not null && - (!string.IsNullOrWhiteSpace(publishConfig.Repository.Uri) || - !string.IsNullOrWhiteSpace(publishConfig.Repository.SourceUri) || - !string.IsNullOrWhiteSpace(publishConfig.Repository.PublishUri) || - publishConfig.Repository.Credential is not null)) - { - return FailedReceipt(repository.RootPath, repository.Name, ReleaseBuildAdapterKind.ModuleBuild.ToString(), "Module publish", destination, "Custom repository registration flows are not wired yet. Start with PSGallery or an already-registered repository."); - } - if (string.IsNullOrWhiteSpace(publishConfig.ApiKey)) { return FailedReceipt(repository.RootPath, repository.Name, ReleaseBuildAdapterKind.ModuleBuild.ToString(), "Module publish", destination, "Module publish is enabled but no API key was resolved."); } - var script = BuildModuleRepositoryPublishScript(packageDetails.PackagePath, destination, publishConfig.ApiKey!, publishConfig.Tool); - var execution = await _commandRunner.RunCommandAsync(repository.RootPath, script, cancellationToken); - return new ReleasePublishReceipt( - RootPath: repository.RootPath, - RepositoryName: repository.Name, - AdapterKind: ReleaseBuildAdapterKind.ModuleBuild.ToString(), - TargetName: packageDetails.ModuleName, - TargetKind: "PowerShellRepository", - Destination: destination, - Status: execution.ExitCode == 0 ? ReleasePublishReceiptStatus.Published : ReleasePublishReceiptStatus.Failed, - Summary: execution.ExitCode == 0 - ? $"Module published to {destination}." - : FirstLine(execution.StandardError) ?? FirstLine(execution.StandardOutput) ?? "Module publish failed.", - PublishedAtUtc: DateTimeOffset.UtcNow, - SourcePath: packageDetails.PackagePath); + try + { + var publishResult = await _publishRepositoryAsync( + new RepositoryPublishRequest { + Path = packageDetails.PackagePath, + IsNupkg = false, + RepositoryName = destination, + Tool = publishConfig.Tool, + ApiKey = publishConfig.ApiKey, + Repository = publishConfig.Repository, + SkipDependenciesCheck = true, + SkipModuleManifestValidate = false + }, + cancellationToken).ConfigureAwait(false); + + return ReleaseQueueReceiptFactory.CreatePublishReceipt( + repository.RootPath, + repository.Name, + ReleaseBuildAdapterKind.ModuleBuild.ToString(), + packageDetails.ModuleName, + "PowerShellRepository", + publishResult.RepositoryName, + ReleasePublishReceiptStatus.Published, + $"Module published to {publishResult.RepositoryName} using {publishResult.Tool}.", + packageDetails.PackagePath); + } + catch (Exception ex) + { + return ReleaseQueueReceiptFactory.FailedPublishReceipt( + repository.RootPath, + repository.Name, + ReleaseBuildAdapterKind.ModuleBuild.ToString(), + packageDetails.ModuleName, + destination, + FirstLine(ex.Message) ?? "Module publish failed.", + "PowerShellRepository", + packageDetails.PackagePath); + } } private async Task ExecuteModuleGitHubPublishAsync( PowerForgeStudio.Domain.Catalog.RepositoryCatalogEntry repository, - ModulePublishConfig publishConfig, + PublishConfiguration publishConfig, ModulePackageDetails? packageDetails, CancellationToken cancellationToken) { @@ -910,26 +602,20 @@ private async Task ExecuteModuleGitHubPublishAsync( } var repoName = string.IsNullOrWhiteSpace(publishConfig.RepositoryName) ? repository.Name : publishConfig.RepositoryName!.Trim(); - var versionWithPreRelease = string.IsNullOrWhiteSpace(packageDetails.PreRelease) - ? packageDetails.Version - : $"{packageDetails.Version}-{packageDetails.PreRelease}"; - var tag = string.IsNullOrWhiteSpace(publishConfig.OverwriteTagName) - ? $"v{versionWithPreRelease}" - : ReplaceModuleTokens(publishConfig.OverwriteTagName!, packageDetails.ModuleName, packageDetails.Version, packageDetails.PreRelease); + var tag = new ModulePublishTagBuilder().BuildTag(publishConfig, packageDetails.ModuleName, packageDetails.Version, packageDetails.PreRelease); var isPreRelease = !string.IsNullOrWhiteSpace(packageDetails.PreRelease) && !publishConfig.DoNotMarkAsPreRelease; var execution = await PublishGitHubReleaseAsync(repository.RootPath, publishConfig.UserName!, repoName, publishConfig.ApiKey!, tag, tag, packageDetails.ZipAssets, publishConfig.GenerateReleaseNotes, isPreRelease, cancellationToken); - return new ReleasePublishReceipt( - RootPath: repository.RootPath, - RepositoryName: repository.Name, - AdapterKind: ReleaseBuildAdapterKind.ModuleBuild.ToString(), - TargetName: "GitHub release", - TargetKind: "GitHub", - Destination: execution.ReleaseUrl ?? $"{publishConfig.UserName}/{repoName}", - Status: execution.Succeeded ? ReleasePublishReceiptStatus.Published : ReleasePublishReceiptStatus.Failed, - Summary: execution.Succeeded ? $"GitHub release {tag} published." : execution.ErrorMessage!, - PublishedAtUtc: DateTimeOffset.UtcNow, - SourcePath: packageDetails.ZipAssets.FirstOrDefault()); + return ReleaseQueueReceiptFactory.CreatePublishReceipt( + repository.RootPath, + repository.Name, + ReleaseBuildAdapterKind.ModuleBuild.ToString(), + "GitHub release", + "GitHub", + execution.ReleaseUrl ?? $"{publishConfig.UserName}/{repoName}", + execution.Succeeded ? ReleasePublishReceiptStatus.Published : ReleasePublishReceiptStatus.Failed, + execution.Succeeded ? $"GitHub release {tag} published." : execution.ErrorMessage!, + packageDetails.ZipAssets.FirstOrDefault()); } } @@ -941,21 +627,20 @@ private async Task> ExecuteProjectPublishAs CancellationToken cancellationToken) { var scriptPath = repository.ProjectBuildScriptPath!; - var configPath = Path.Combine(Path.GetDirectoryName(scriptPath)!, "project.build.json"); - if (!File.Exists(configPath)) + var configPath = RepositoryPlanPreviewService.ResolveProjectConfigPath(scriptPath, repository.RootPath); + if (string.IsNullOrWhiteSpace(configPath) || !File.Exists(configPath)) { return [ FailedReceipt(repository.RootPath, repository.Name, ReleaseBuildAdapterKind.ProjectBuild.ToString(), "Project publish", null, $"Project config was not found at {configPath}.") ]; } - var config = LoadProjectPublishConfig(configPath); + var config = _projectBuildPublishHostService.LoadConfiguration(configPath); var receipts = new List(); - if (config.PublishNuget == true) + if (config.PublishNuget) { - var apiKey = ResolveSecret(config.PublishApiKey, config.PublishApiKeyFilePath, config.PublishApiKeyEnvName, Path.GetDirectoryName(configPath)!); - if (string.IsNullOrWhiteSpace(apiKey)) + if (string.IsNullOrWhiteSpace(config.PublishApiKey)) { receipts.Add(FailedReceipt(repository.RootPath, repository.Name, ReleaseBuildAdapterKind.ProjectBuild.ToString(), "NuGet publish", config.PublishSource, "NuGet publishing is enabled but no API key was resolved.")); } @@ -977,24 +662,23 @@ private async Task> ExecuteProjectPublishAs { foreach (var package in packages) { - var result = await PublishNugetPackageAsync(package, apiKey, config.PublishSource ?? "https://api.nuget.org/v3/index.json", cancellationToken); - receipts.Add(new ReleasePublishReceipt( - RootPath: repository.RootPath, - RepositoryName: repository.Name, - AdapterKind: ReleaseBuildAdapterKind.ProjectBuild.ToString(), - TargetName: Path.GetFileName(package), - TargetKind: "NuGet", - Destination: config.PublishSource ?? "https://api.nuget.org/v3/index.json", - Status: result.Succeeded ? ReleasePublishReceiptStatus.Published : ReleasePublishReceiptStatus.Failed, - Summary: result.Succeeded ? "Package pushed with dotnet nuget push." : result.ErrorMessage!, - PublishedAtUtc: DateTimeOffset.UtcNow, - SourcePath: package)); + var result = await PublishNugetPackageAsync(package, config.PublishApiKey!, config.PublishSource, cancellationToken); + receipts.Add(ReleaseQueueReceiptFactory.CreatePublishReceipt( + repository.RootPath, + repository.Name, + ReleaseBuildAdapterKind.ProjectBuild.ToString(), + Path.GetFileName(package), + "NuGet", + config.PublishSource, + result.Succeeded ? ReleasePublishReceiptStatus.Published : ReleasePublishReceiptStatus.Failed, + result.Succeeded ? "Package pushed with dotnet nuget push." : result.ErrorMessage!, + package)); } } } } - if (config.PublishGitHub == true) + if (config.PublishGitHub) { receipts.AddRange(await ExecuteProjectGitHubPublishAsync(repository, config, signingResult, cancellationToken)); } @@ -1004,14 +688,11 @@ private async Task> ExecuteProjectPublishAs private async Task> ExecuteProjectGitHubPublishAsync( PowerForgeStudio.Domain.Catalog.RepositoryCatalogEntry repository, - ProjectPublishConfig config, + ProjectBuildPublishHostConfiguration config, ReleaseSigningExecutionResult signingResult, CancellationToken cancellationToken) { - var configPath = Path.Combine(Path.GetDirectoryName(repository.ProjectBuildScriptPath!)!, "project.build.json"); - var configDirectory = Path.GetDirectoryName(configPath)!; - var token = ResolveSecret(config.GitHubAccessToken, config.GitHubAccessTokenFilePath, config.GitHubAccessTokenEnvName, configDirectory); - if (string.IsNullOrWhiteSpace(token)) + if (string.IsNullOrWhiteSpace(config.GitHubToken)) { return [ FailedReceipt(repository.RootPath, repository.Name, ReleaseBuildAdapterKind.ProjectBuild.ToString(), "GitHub release", null, "GitHub publishing is enabled but no access token was resolved.") @@ -1033,158 +714,54 @@ private async Task> ExecuteProjectGitHubPub ]; } - var releaseMode = string.IsNullOrWhiteSpace(config.GitHubReleaseMode) ? "Single" : config.GitHubReleaseMode!.Trim(); - var perProject = string.Equals(releaseMode, "PerProject", StringComparison.OrdinalIgnoreCase); - var nowLocal = DateTime.Now; - var nowUtc = DateTime.UtcNow; - var dateToken = nowLocal.ToString("yyyy.MM.dd"); - var utcDateToken = nowUtc.ToString("yyyy.MM.dd"); - var dateTimeToken = nowLocal.ToString("yyyy.MM.dd-HH.mm.ss"); - var utcDateTimeToken = nowUtc.ToString("yyyy.MM.dd-HH.mm.ss"); - var timestampToken = nowLocal.ToString("yyyyMMddHHmmss"); - var utcTimestampToken = nowUtc.ToString("yyyyMMddHHmmss"); var repoName = config.GitHubRepositoryName!.Trim(); var owner = config.GitHubUsername!.Trim(); - - if (perProject) - { - var receipts = new List(); - foreach (var project in plan.Projects.Where(candidate => candidate.IsPackable)) - { - var version = project.NewVersion ?? project.OldVersion; - if (string.IsNullOrWhiteSpace(version)) - { - receipts.Add(FailedReceipt(repository.RootPath, repository.Name, ReleaseBuildAdapterKind.ProjectBuild.ToString(), $"{project.ProjectName} GitHub release", $"{owner}/{repoName}", "Project version could not be resolved for GitHub publishing.")); - continue; - } - - var zipPath = !string.IsNullOrWhiteSpace(project.ReleaseZipPath) && File.Exists(project.ReleaseZipPath) - ? project.ReleaseZipPath - : FindZipAsset(signingResult, project.ProjectName); - if (string.IsNullOrWhiteSpace(zipPath)) - { - receipts.Add(FailedReceipt(repository.RootPath, repository.Name, ReleaseBuildAdapterKind.ProjectBuild.ToString(), $"{project.ProjectName} GitHub release", $"{owner}/{repoName}", "No release zip was found for GitHub publishing.")); - continue; - } - - var tag = string.IsNullOrWhiteSpace(config.GitHubTagName) - ? (config.GitHubIncludeProjectNameInTag == false ? $"v{version}" : $"{project.ProjectName}-v{version}") - : config.GitHubTagName!; - if (!string.IsNullOrWhiteSpace(config.GitHubTagTemplate)) - { - tag = ApplyProjectTemplate( - config.GitHubTagTemplate!, - project.ProjectName, - version, - config.GitHubPrimaryProject ?? project.ProjectName, - version, - repoName, - dateToken, - utcDateToken, - dateTimeToken, - utcDateTimeToken, - timestampToken, - utcTimestampToken); - } - - var releaseName = string.IsNullOrWhiteSpace(config.GitHubReleaseName) - ? tag - : ApplyProjectTemplate( - config.GitHubReleaseName!, - project.ProjectName, - version, - config.GitHubPrimaryProject ?? project.ProjectName, - version, - repoName, - dateToken, - utcDateToken, - dateTimeToken, - utcDateTimeToken, - timestampToken, - utcTimestampToken); - - var execution = await PublishGitHubReleaseAsync(repository.RootPath, owner, repoName, token, tag, releaseName, [zipPath], config.GitHubGenerateReleaseNotes, config.GitHubIsPreRelease, cancellationToken); - receipts.Add(new ReleasePublishReceipt( - RootPath: repository.RootPath, - RepositoryName: repository.Name, - AdapterKind: ReleaseBuildAdapterKind.ProjectBuild.ToString(), - TargetName: $"{project.ProjectName} GitHub release", - TargetKind: "GitHub", - Destination: execution.ReleaseUrl ?? $"{owner}/{repoName}", - Status: execution.Succeeded ? ReleasePublishReceiptStatus.Published : ReleasePublishReceiptStatus.Failed, - Summary: execution.Succeeded ? $"GitHub release {tag} published." : execution.ErrorMessage!, - PublishedAtUtc: DateTimeOffset.UtcNow, - SourcePath: zipPath)); - } - - return receipts; - } - - var assets = plan.Projects - .Where(candidate => candidate.IsPackable) - .Select(candidate => !string.IsNullOrWhiteSpace(candidate.ReleaseZipPath) && File.Exists(candidate.ReleaseZipPath) - ? candidate.ReleaseZipPath! - : FindZipAsset(signingResult, candidate.ProjectName)) - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (assets.Length == 0) + var publishSummary = _projectBuildPublishHostService.PublishGitHub(config, plan); + + if (publishSummary.PerProject) + { + return plan.Projects + .Where(project => project.IsPackable) + .Select(project => { + var publishResult = publishSummary.Results.FirstOrDefault(result => string.Equals(result.ProjectName, project.ProjectName, StringComparison.OrdinalIgnoreCase)); + var sourcePath = ResolveProjectGitHubAssets(plan, signingResult, project.ProjectName).FirstOrDefault(); + return ReleaseQueueReceiptFactory.CreatePublishReceipt( + repository.RootPath, + repository.Name, + ReleaseBuildAdapterKind.ProjectBuild.ToString(), + $"{project.ProjectName} GitHub release", + "GitHub", + publishResult?.ReleaseUrl ?? $"{owner}/{repoName}", + publishResult?.Success == true ? ReleasePublishReceiptStatus.Published : ReleasePublishReceiptStatus.Failed, + publishResult?.Success == true + ? $"GitHub release {publishResult.TagName} published." + : publishResult?.ErrorMessage ?? "GitHub publish failed.", + sourcePath); + }) + .ToList(); + } + + var assets = ResolveProjectGitHubAssets(plan, signingResult); + if (assets.Count == 0) { return [ FailedReceipt(repository.RootPath, repository.Name, ReleaseBuildAdapterKind.ProjectBuild.ToString(), "GitHub release", $"{owner}/{repoName}", "No release zips were found for GitHub publishing.") ]; } - var baseVersion = ResolveGitHubBaseVersion(config, plan); - var tagVersionToken = string.IsNullOrWhiteSpace(baseVersion) ? dateToken : baseVersion!; - var singleTag = !string.IsNullOrWhiteSpace(config.GitHubTagName) - ? config.GitHubTagName! - : (!string.IsNullOrWhiteSpace(config.GitHubTagTemplate) - ? ApplyProjectTemplate( - config.GitHubTagTemplate!, - repoName, - tagVersionToken, - config.GitHubPrimaryProject ?? repoName, - tagVersionToken, - repoName, - dateToken, - utcDateToken, - dateTimeToken, - utcDateTimeToken, - timestampToken, - utcTimestampToken) - : $"v{tagVersionToken}"); - - var releaseNameSingle = string.IsNullOrWhiteSpace(config.GitHubReleaseName) - ? singleTag - : ApplyProjectTemplate( - config.GitHubReleaseName!, - repoName, - tagVersionToken, - config.GitHubPrimaryProject ?? repoName, - tagVersionToken, - repoName, - dateToken, - utcDateToken, - dateTimeToken, - utcDateTimeToken, - timestampToken, - utcTimestampToken); - - var singleExecution = await PublishGitHubReleaseAsync(repository.RootPath, owner, repoName, token, singleTag, releaseNameSingle, assets!, config.GitHubGenerateReleaseNotes, config.GitHubIsPreRelease, cancellationToken); return [ - new ReleasePublishReceipt( - RootPath: repository.RootPath, - RepositoryName: repository.Name, - AdapterKind: ReleaseBuildAdapterKind.ProjectBuild.ToString(), - TargetName: "GitHub release", - TargetKind: "GitHub", - Destination: singleExecution.ReleaseUrl ?? $"{owner}/{repoName}", - Status: singleExecution.Succeeded ? ReleasePublishReceiptStatus.Published : ReleasePublishReceiptStatus.Failed, - Summary: singleExecution.Succeeded ? $"GitHub release {singleTag} published with {assets.Length} asset(s)." : singleExecution.ErrorMessage!, - PublishedAtUtc: DateTimeOffset.UtcNow, - SourcePath: assets.FirstOrDefault()) + ReleaseQueueReceiptFactory.CreatePublishReceipt( + repository.RootPath, + repository.Name, + ReleaseBuildAdapterKind.ProjectBuild.ToString(), + "GitHub release", + "GitHub", + publishSummary.SummaryReleaseUrl ?? $"{owner}/{repoName}", + publishSummary.Success ? ReleasePublishReceiptStatus.Published : ReleasePublishReceiptStatus.Failed, + publishSummary.Success + ? $"GitHub release {publishSummary.SummaryTag} published with {assets.Count} asset(s)." + : publishSummary.ErrorMessage ?? "GitHub publish failed.", + assets.FirstOrDefault()) ]; } } diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCheckpointSerializer.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCheckpointSerializer.cs new file mode 100644 index 00000000..00e8f8c9 --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCheckpointSerializer.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using PowerForgeStudio.Domain.Queue; + +namespace PowerForgeStudio.Orchestrator.Queue; + +public sealed class ReleaseQueueCheckpointSerializer +{ + private static readonly JsonSerializerOptions SerializerOptions = new() { + PropertyNameCaseInsensitive = true + }; + + public string Serialize(T value) + { + ArgumentNullException.ThrowIfNull(value); + return JsonSerializer.Serialize(value, SerializerOptions); + } + + public string SerializeTransition(string fromStage, string toStage, DateTimeOffset timestamp) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fromStage); + ArgumentException.ThrowIfNullOrWhiteSpace(toStage); + + return Serialize(new Dictionary { + ["from"] = fromStage, + ["to"] = toStage, + ["updatedAtUtc"] = timestamp.ToString("O") + }); + } + + public T? TryDeserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return default; + } + + try + { + return JsonSerializer.Deserialize(json, SerializerOptions); + } + catch + { + return default; + } + } + + public T? TryRead(ReleaseQueueItem item, string expectedCheckpointKey) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedCheckpointKey); + + if (!string.Equals(item.CheckpointKey, expectedCheckpointKey, StringComparison.OrdinalIgnoreCase)) + { + return default; + } + + return TryDeserialize(item.CheckpointStateJson); + } +} diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCommandService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCommandService.cs index 9989a87c..d6a2834a 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCommandService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCommandService.cs @@ -6,6 +6,7 @@ namespace PowerForgeStudio.Orchestrator.Queue; public sealed class ReleaseQueueCommandService : IReleaseQueueCommandService { + private readonly ReleaseQueueCommandStateService _commandStateService; private readonly ReleaseQueuePlanner _queuePlanner; private readonly ReleaseQueueRunner _queueRunner; private readonly IReleaseBuildExecutionService _buildExecutionService; @@ -31,6 +32,7 @@ public ReleaseQueueCommandService( IReleasePublishExecutionService publishExecutionService, IReleaseVerificationExecutionService verificationExecutionService) { + _commandStateService = new ReleaseQueueCommandStateService(); _queuePlanner = queuePlanner; _queueRunner = queueRunner; _buildExecutionService = buildExecutionService; @@ -41,19 +43,18 @@ public ReleaseQueueCommandService( public async Task RunNextReadyItemAsync(string databasePath, CancellationToken cancellationToken = default) { - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(cancellationToken).ConfigureAwait(false); + var stateDatabase = await _commandStateService.OpenDatabaseAsync(databasePath, cancellationToken).ConfigureAwait(false); var currentSession = await stateDatabase.LoadLatestQueueSessionAsync(cancellationToken).ConfigureAwait(false); if (currentSession is null) { - return EmptyResult("Queue state is not available yet. Prepare the queue first."); + return _commandStateService.EmptyResult("Queue state is not available yet. Prepare the queue first."); } var nextReadyItem = currentSession.Items.FirstOrDefault(item => item.Status == ReleaseQueueItemStatus.ReadyToRun); if (nextReadyItem is null) { - return await LoadResultAsync( + return await _commandStateService.LoadResultAsync( stateDatabase, currentSession, changed: false, @@ -90,24 +91,23 @@ public async Task RunNextReadyItemAsync(string databa transition = _queueRunner.AdvanceNextReadyItem(currentSession); } - return await PersistTransitionResultAsync(stateDatabase, currentSession, transition, cancellationToken).ConfigureAwait(false); + return await _commandStateService.PersistTransitionResultAsync(stateDatabase, currentSession, transition, cancellationToken).ConfigureAwait(false); } public async Task ApproveUsbAsync(string databasePath, CancellationToken cancellationToken = default) { - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(cancellationToken).ConfigureAwait(false); + var stateDatabase = await _commandStateService.OpenDatabaseAsync(databasePath, cancellationToken).ConfigureAwait(false); var currentSession = await stateDatabase.LoadLatestQueueSessionAsync(cancellationToken).ConfigureAwait(false); if (currentSession is null) { - return EmptyResult("Queue state is not available yet. Prepare the queue first."); + return _commandStateService.EmptyResult("Queue state is not available yet. Prepare the queue first."); } var waitingItem = currentSession.Items.FirstOrDefault(item => item.Stage == ReleaseQueueStage.Sign && item.Status == ReleaseQueueItemStatus.WaitingApproval); if (waitingItem is null) { - return await LoadResultAsync( + return await _commandStateService.LoadResultAsync( stateDatabase, currentSession, changed: false, @@ -122,7 +122,7 @@ public async Task ApproveUsbAsync(string databasePath ? _queueRunner.CompleteSigning(currentSession, waitingItem.RootPath, signingResult) : _queueRunner.FailSigning(currentSession, waitingItem.RootPath, signingResult); - return await PersistTransitionResultAsync(stateDatabase, currentSession, transition, cancellationToken).ConfigureAwait(false); + return await _commandStateService.PersistTransitionResultAsync(stateDatabase, currentSession, transition, cancellationToken).ConfigureAwait(false); } public Task RetryFailedAsync(string databasePath, CancellationToken cancellationToken = default) @@ -151,11 +151,10 @@ public async Task PrepareQueueAsync( if (portfolioItems.Count == 0) { - return EmptyResult("No portfolio items are currently available for queue preparation."); + return _commandStateService.EmptyResult("No portfolio items are currently available for queue preparation."); } - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(cancellationToken).ConfigureAwait(false); + var stateDatabase = await _commandStateService.OpenDatabaseAsync(databasePath, cancellationToken).ConfigureAwait(false); var queueSession = _queuePlanner.CreateDraftQueue( workspaceRoot, @@ -164,7 +163,7 @@ public async Task PrepareQueueAsync( scopeDisplayName); await stateDatabase.PersistQueueSessionAsync(queueSession, cancellationToken).ConfigureAwait(false); - return await LoadResultAsync( + return await _commandStateService.LoadResultAsync( stateDatabase, queueSession, changed: true, @@ -179,69 +178,15 @@ private async Task UpdateQueueAsync( Func transition, CancellationToken cancellationToken) { - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(cancellationToken).ConfigureAwait(false); + var stateDatabase = await _commandStateService.OpenDatabaseAsync(databasePath, cancellationToken).ConfigureAwait(false); var currentSession = await stateDatabase.LoadLatestQueueSessionAsync(cancellationToken).ConfigureAwait(false); if (currentSession is null) { - return EmptyResult("Queue state is not available yet. Prepare the queue first."); + return _commandStateService.EmptyResult("Queue state is not available yet. Prepare the queue first."); } var result = transition(currentSession); - return await PersistTransitionResultAsync(stateDatabase, currentSession, result, cancellationToken).ConfigureAwait(false); + return await _commandStateService.PersistTransitionResultAsync(stateDatabase, currentSession, result, cancellationToken).ConfigureAwait(false); } - - private static async Task PersistTransitionResultAsync( - ReleaseStateDatabase stateDatabase, - ReleaseQueueSession currentSession, - ReleaseQueueTransitionResult transition, - CancellationToken cancellationToken) - { - if (!transition.Changed) - { - return await LoadResultAsync(stateDatabase, currentSession, false, transition.Message, cancellationToken).ConfigureAwait(false); - } - - await stateDatabase.PersistQueueSessionAsync(transition.Session, cancellationToken).ConfigureAwait(false); - return await LoadResultAsync(stateDatabase, transition.Session, true, transition.Message, cancellationToken).ConfigureAwait(false); - } - - private static async Task LoadResultAsync( - ReleaseStateDatabase stateDatabase, - ReleaseQueueSession? fallbackSession, - bool changed, - string message, - CancellationToken cancellationToken) - { - var persistedQueue = fallbackSession is null - ? null - : await stateDatabase.LoadLatestQueueSessionAsync(cancellationToken).ConfigureAwait(false) ?? fallbackSession; - - if (persistedQueue is null) - { - return EmptyResult(message); - } - - var signingReceipts = await stateDatabase.LoadSigningReceiptsAsync(persistedQueue.SessionId, cancellationToken).ConfigureAwait(false); - var publishReceipts = await stateDatabase.LoadPublishReceiptsAsync(persistedQueue.SessionId, cancellationToken).ConfigureAwait(false); - var verificationReceipts = await stateDatabase.LoadVerificationReceiptsAsync(persistedQueue.SessionId, cancellationToken).ConfigureAwait(false); - - return new ReleaseQueueCommandResult( - Changed: changed, - Message: message, - QueueSession: persistedQueue, - SigningReceipts: signingReceipts, - PublishReceipts: publishReceipts, - VerificationReceipts: verificationReceipts); - } - - private static ReleaseQueueCommandResult EmptyResult(string message) - => new( - Changed: false, - Message: message, - QueueSession: null, - SigningReceipts: [], - PublishReceipts: [], - VerificationReceipts: []); } diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCommandStateService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCommandStateService.cs new file mode 100644 index 00000000..5384ac7d --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueCommandStateService.cs @@ -0,0 +1,67 @@ +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Orchestrator.Storage; + +namespace PowerForgeStudio.Orchestrator.Queue; + +public sealed class ReleaseQueueCommandStateService +{ + public async Task OpenDatabaseAsync(string databasePath, CancellationToken cancellationToken = default) + { + var stateDatabase = new ReleaseStateDatabase(databasePath); + await stateDatabase.InitializeAsync(cancellationToken).ConfigureAwait(false); + return stateDatabase; + } + + public async Task PersistTransitionResultAsync( + ReleaseStateDatabase stateDatabase, + ReleaseQueueSession currentSession, + ReleaseQueueTransitionResult transition, + CancellationToken cancellationToken = default) + { + if (!transition.Changed) + { + return await LoadResultAsync(stateDatabase, currentSession, false, transition.Message, cancellationToken).ConfigureAwait(false); + } + + await stateDatabase.PersistQueueSessionAsync(transition.Session, cancellationToken).ConfigureAwait(false); + return await LoadResultAsync(stateDatabase, transition.Session, true, transition.Message, cancellationToken).ConfigureAwait(false); + } + + public async Task LoadResultAsync( + ReleaseStateDatabase stateDatabase, + ReleaseQueueSession? fallbackSession, + bool changed, + string message, + CancellationToken cancellationToken = default) + { + var persistedQueue = fallbackSession is null + ? null + : await stateDatabase.LoadLatestQueueSessionAsync(cancellationToken).ConfigureAwait(false) ?? fallbackSession; + + if (persistedQueue is null) + { + return EmptyResult(message); + } + + var signingReceipts = await stateDatabase.LoadSigningReceiptsAsync(persistedQueue.SessionId, cancellationToken).ConfigureAwait(false); + var publishReceipts = await stateDatabase.LoadPublishReceiptsAsync(persistedQueue.SessionId, cancellationToken).ConfigureAwait(false); + var verificationReceipts = await stateDatabase.LoadVerificationReceiptsAsync(persistedQueue.SessionId, cancellationToken).ConfigureAwait(false); + + return new ReleaseQueueCommandResult( + Changed: changed, + Message: message, + QueueSession: persistedQueue, + SigningReceipts: signingReceipts, + PublishReceipts: publishReceipts, + VerificationReceipts: verificationReceipts); + } + + public ReleaseQueueCommandResult EmptyResult(string message) + => new( + Changed: false, + Message: message, + QueueSession: null, + SigningReceipts: [], + PublishReceipts: [], + VerificationReceipts: []); +} diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueExecutionResultFactory.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueExecutionResultFactory.cs new file mode 100644 index 00000000..8172b8c6 --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueExecutionResultFactory.cs @@ -0,0 +1,80 @@ +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Domain.Publish; +using PowerForgeStudio.Domain.Verification; + +namespace PowerForgeStudio.Orchestrator.Queue; + +public static class ReleaseQueueExecutionResultFactory +{ + public static ReleaseBuildExecutionResult CreateBuildResult( + string rootPath, + TimeSpan duration, + IReadOnlyList adapterResults) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); + ArgumentNullException.ThrowIfNull(adapterResults); + + var succeeded = adapterResults.Count > 0 && adapterResults.All(result => result.Succeeded); + var summary = succeeded + ? $"Build completed for {adapterResults.Count} adapter(s) without publish/install side effects." + : FirstLine(adapterResults.FirstOrDefault(result => !result.Succeeded)?.ErrorTail + ?? adapterResults.FirstOrDefault(result => !result.Succeeded)?.OutputTail + ?? "Build execution failed."); + + return new ReleaseBuildExecutionResult( + RootPath: rootPath, + Succeeded: succeeded, + Summary: summary, + DurationSeconds: Math.Round(duration.TotalSeconds, 2), + AdapterResults: adapterResults); + } + + public static ReleasePublishExecutionResult CreatePublishResult( + ReleaseQueueItem queueItem, + IReadOnlyList receipts) + { + ArgumentNullException.ThrowIfNull(queueItem); + ArgumentNullException.ThrowIfNull(receipts); + + var published = receipts.Count(receipt => receipt.Status == Domain.Publish.ReleasePublishReceiptStatus.Published); + var skipped = receipts.Count(receipt => receipt.Status == Domain.Publish.ReleasePublishReceiptStatus.Skipped); + var failed = receipts.Count(receipt => receipt.Status == Domain.Publish.ReleasePublishReceiptStatus.Failed); + var summary = failed > 0 + ? $"Publish completed with {published} published, {skipped} skipped, and {failed} failed target(s)." + : $"Publish completed with {published} published and {skipped} skipped target(s)."; + + return new ReleasePublishExecutionResult( + RootPath: queueItem.RootPath, + Succeeded: failed == 0, + Summary: summary, + SourceCheckpointStateJson: queueItem.CheckpointStateJson, + Receipts: receipts); + } + + public static ReleaseVerificationExecutionResult CreateVerificationResult( + ReleaseQueueItem queueItem, + IReadOnlyList receipts) + { + ArgumentNullException.ThrowIfNull(queueItem); + ArgumentNullException.ThrowIfNull(receipts); + + var verified = receipts.Count(receipt => receipt.Status == Domain.Verification.ReleaseVerificationReceiptStatus.Verified); + var skipped = receipts.Count(receipt => receipt.Status == Domain.Verification.ReleaseVerificationReceiptStatus.Skipped); + var failed = receipts.Count(receipt => receipt.Status == Domain.Verification.ReleaseVerificationReceiptStatus.Failed); + var summary = failed > 0 + ? $"Verification completed with {verified} verified, {skipped} skipped, and {failed} failed check(s)." + : $"Verification completed with {verified} verified and {skipped} skipped check(s)."; + + return new ReleaseVerificationExecutionResult( + RootPath: queueItem.RootPath, + Succeeded: failed == 0, + Summary: summary, + SourceCheckpointStateJson: queueItem.CheckpointStateJson, + Receipts: receipts); + } + + private static string FirstLine(string? value) + => string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? value; +} diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueItemFactory.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueItemFactory.cs new file mode 100644 index 00000000..02474b85 --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueItemFactory.cs @@ -0,0 +1,67 @@ +using PowerForgeStudio.Domain.Portfolio; +using PowerForgeStudio.Domain.Queue; + +namespace PowerForgeStudio.Orchestrator.Queue; + +public static class ReleaseQueueItemFactory +{ + public static ReleaseQueueItem CreateFromPortfolioItem(RepositoryPortfolioItem item, int queueOrder, DateTimeOffset timestamp) + { + ArgumentNullException.ThrowIfNull(item); + + if (item.ReadinessKind == RepositoryReadinessKind.Blocked) + { + return CreateItem(item, queueOrder, ReleaseQueueStage.Prepare, ReleaseQueueItemStatus.Blocked, item.ReadinessReason, "prepare.blocked.readiness", timestamp); + } + + var planResults = item.PlanResults ?? []; + var failedPlan = planResults.FirstOrDefault(result => result.Status == RepositoryPlanStatus.Failed); + if (failedPlan is not null) + { + return CreateItem( + item, + queueOrder, + ReleaseQueueStage.Prepare, + ReleaseQueueItemStatus.Blocked, + FirstLine(failedPlan.ErrorTail ?? failedPlan.OutputTail ?? failedPlan.Summary), + "prepare.blocked.plan", + timestamp); + } + + if (item.ReadinessKind == RepositoryReadinessKind.Attention) + { + return CreateItem(item, queueOrder, ReleaseQueueStage.Prepare, ReleaseQueueItemStatus.Pending, item.ReadinessReason, "prepare.pending.readiness", timestamp); + } + + if (planResults.Count == 0) + { + return CreateItem(item, queueOrder, ReleaseQueueStage.Prepare, ReleaseQueueItemStatus.Pending, "Plan preview has not run yet.", "prepare.pending.plan", timestamp); + } + + return CreateItem(item, queueOrder, ReleaseQueueStage.Build, ReleaseQueueItemStatus.ReadyToRun, "Prepare checks passed. Ready for build execution.", "build.ready", timestamp); + } + + private static ReleaseQueueItem CreateItem( + RepositoryPortfolioItem item, + int queueOrder, + ReleaseQueueStage stage, + ReleaseQueueItemStatus status, + string summary, + string checkpointKey, + DateTimeOffset timestamp) + => new( + RootPath: item.RootPath, + RepositoryName: item.Name, + RepositoryKind: item.RepositoryKind, + WorkspaceKind: item.WorkspaceKind, + QueueOrder: queueOrder, + Stage: stage, + Status: status, + Summary: summary, + CheckpointKey: checkpointKey, + CheckpointStateJson: null, + UpdatedAtUtc: timestamp); + + private static string FirstLine(string value) + => value.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? value; +} diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueItemTransitionFactory.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueItemTransitionFactory.cs new file mode 100644 index 00000000..61a37d87 --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueItemTransitionFactory.cs @@ -0,0 +1,83 @@ +using PowerForgeStudio.Domain.Queue; + +namespace PowerForgeStudio.Orchestrator.Queue; + +public sealed class ReleaseQueueItemTransitionFactory +{ + private readonly ReleaseQueueCheckpointSerializer _checkpointSerializer; + + public ReleaseQueueItemTransitionFactory() + : this(new ReleaseQueueCheckpointSerializer()) + { + } + + internal ReleaseQueueItemTransitionFactory(ReleaseQueueCheckpointSerializer checkpointSerializer) + { + _checkpointSerializer = checkpointSerializer ?? throw new ArgumentNullException(nameof(checkpointSerializer)); + } + + public ReleaseQueueItem CreateTransition( + ReleaseQueueItem item, + string fromStage, + ReleaseQueueStage targetStage, + ReleaseQueueItemStatus targetStatus, + string summary, + string checkpointKey, + DateTimeOffset timestamp) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fromStage); + + return CreateStateUpdate( + item, + targetStage, + targetStatus, + summary, + checkpointKey, + _checkpointSerializer.SerializeTransition(fromStage, targetStage.ToString(), timestamp), + timestamp); + } + + public ReleaseQueueItem CreateCheckpointUpdate( + ReleaseQueueItem item, + ReleaseQueueStage targetStage, + ReleaseQueueItemStatus targetStatus, + string summary, + string checkpointKey, + TCheckpoint checkpoint, + DateTimeOffset timestamp) + { + ArgumentNullException.ThrowIfNull(checkpoint); + + return CreateStateUpdate( + item, + targetStage, + targetStatus, + summary, + checkpointKey, + _checkpointSerializer.Serialize(checkpoint), + timestamp); + } + + public ReleaseQueueItem CreateStateUpdate( + ReleaseQueueItem item, + ReleaseQueueStage targetStage, + ReleaseQueueItemStatus targetStatus, + string summary, + string checkpointKey, + string? checkpointStateJson, + DateTimeOffset timestamp) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentException.ThrowIfNullOrWhiteSpace(summary); + ArgumentException.ThrowIfNullOrWhiteSpace(checkpointKey); + + return item with { + Stage = targetStage, + Status = targetStatus, + Summary = summary, + CheckpointKey = checkpointKey, + CheckpointStateJson = checkpointStateJson, + UpdatedAtUtc = timestamp + }; + } +} diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueuePlanner.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueuePlanner.cs index af9a46ac..1f22fde4 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueuePlanner.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueuePlanner.cs @@ -23,18 +23,15 @@ public ReleaseQueueSession CreateDraftQueue( var queueItems = new List(orderedItems.Count); for (var index = 0; index < orderedItems.Count; index++) { - queueItems.Add(BuildQueueItem(orderedItems[index], index + 1, createdAtUtc)); + queueItems.Add(ReleaseQueueItemFactory.CreateFromPortfolioItem(orderedItems[index], index + 1, createdAtUtc)); } - var summary = BuildSummary(queueItems); - return new ReleaseQueueSession( - SessionId: Guid.NewGuid().ToString("N"), - WorkspaceRoot: workspaceRoot, - CreatedAtUtc: createdAtUtc, - Summary: summary, - Items: queueItems, - ScopeKey: scopeKey, - ScopeDisplayName: scopeDisplayName); + return ReleaseQueueSessionFactory.Create( + workspaceRoot: workspaceRoot, + items: queueItems, + createdAtUtc: createdAtUtc, + scopeKey: scopeKey, + scopeDisplayName: scopeDisplayName); } private static int GetPriority(RepositoryPortfolioItem item) @@ -63,99 +60,4 @@ private static int GetPriority(RepositoryPortfolioItem item) return item.ReadinessKind == RepositoryReadinessKind.Blocked ? 4 : 2; } - private static ReleaseQueueItem BuildQueueItem(RepositoryPortfolioItem item, int queueOrder, DateTimeOffset timestamp) - { - if (item.ReadinessKind == RepositoryReadinessKind.Blocked) - { - return new ReleaseQueueItem( - RootPath: item.RootPath, - RepositoryName: item.Name, - RepositoryKind: item.RepositoryKind, - WorkspaceKind: item.WorkspaceKind, - QueueOrder: queueOrder, - Stage: ReleaseQueueStage.Prepare, - Status: ReleaseQueueItemStatus.Blocked, - Summary: item.ReadinessReason, - CheckpointKey: "prepare.blocked.readiness", - CheckpointStateJson: null, - UpdatedAtUtc: timestamp); - } - - var planResults = item.PlanResults ?? []; - var failedPlan = planResults.FirstOrDefault(result => result.Status == RepositoryPlanStatus.Failed); - if (failedPlan is not null) - { - return new ReleaseQueueItem( - RootPath: item.RootPath, - RepositoryName: item.Name, - RepositoryKind: item.RepositoryKind, - WorkspaceKind: item.WorkspaceKind, - QueueOrder: queueOrder, - Stage: ReleaseQueueStage.Prepare, - Status: ReleaseQueueItemStatus.Blocked, - Summary: FirstLine(failedPlan.ErrorTail ?? failedPlan.OutputTail ?? failedPlan.Summary), - CheckpointKey: "prepare.blocked.plan", - CheckpointStateJson: null, - UpdatedAtUtc: timestamp); - } - - if (item.ReadinessKind == RepositoryReadinessKind.Attention) - { - return new ReleaseQueueItem( - RootPath: item.RootPath, - RepositoryName: item.Name, - RepositoryKind: item.RepositoryKind, - WorkspaceKind: item.WorkspaceKind, - QueueOrder: queueOrder, - Stage: ReleaseQueueStage.Prepare, - Status: ReleaseQueueItemStatus.Pending, - Summary: item.ReadinessReason, - CheckpointKey: "prepare.pending.readiness", - CheckpointStateJson: null, - UpdatedAtUtc: timestamp); - } - - if (planResults.Count == 0) - { - return new ReleaseQueueItem( - RootPath: item.RootPath, - RepositoryName: item.Name, - RepositoryKind: item.RepositoryKind, - WorkspaceKind: item.WorkspaceKind, - QueueOrder: queueOrder, - Stage: ReleaseQueueStage.Prepare, - Status: ReleaseQueueItemStatus.Pending, - Summary: "Plan preview has not run yet.", - CheckpointKey: "prepare.pending.plan", - CheckpointStateJson: null, - UpdatedAtUtc: timestamp); - } - - return new ReleaseQueueItem( - RootPath: item.RootPath, - RepositoryName: item.Name, - RepositoryKind: item.RepositoryKind, - WorkspaceKind: item.WorkspaceKind, - QueueOrder: queueOrder, - Stage: ReleaseQueueStage.Build, - Status: ReleaseQueueItemStatus.ReadyToRun, - Summary: "Prepare checks passed. Ready for build execution.", - CheckpointKey: "build.ready", - CheckpointStateJson: null, - UpdatedAtUtc: timestamp); - } - - private static ReleaseQueueSummary BuildSummary(IReadOnlyList items) - { - return new ReleaseQueueSummary( - TotalItems: items.Count, - BuildReadyItems: items.Count(item => item.Stage == ReleaseQueueStage.Build && item.Status == ReleaseQueueItemStatus.ReadyToRun), - PreparePendingItems: items.Count(item => item.Stage == ReleaseQueueStage.Prepare && item.Status == ReleaseQueueItemStatus.Pending), - WaitingApprovalItems: items.Count(item => item.Status == ReleaseQueueItemStatus.WaitingApproval), - BlockedItems: items.Count(item => item.Status == ReleaseQueueItemStatus.Blocked || item.Status == ReleaseQueueItemStatus.Failed), - VerificationReadyItems: items.Count(item => item.Stage == ReleaseQueueStage.Verify && item.Status == ReleaseQueueItemStatus.ReadyToRun)); - } - - private static string FirstLine(string value) - => value.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? value; } diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueReceiptFactory.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueReceiptFactory.cs new file mode 100644 index 00000000..1ed25134 --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueReceiptFactory.cs @@ -0,0 +1,103 @@ +using PowerForgeStudio.Domain.Publish; +using PowerForgeStudio.Domain.Verification; + +namespace PowerForgeStudio.Orchestrator.Queue; + +public static class ReleaseQueueReceiptFactory +{ + public static ReleasePublishReceipt CreatePublishReceipt( + string rootPath, + string repositoryName, + string adapterKind, + string targetName, + string targetKind, + string? destination, + ReleasePublishReceiptStatus status, + string summary, + string? sourcePath = null) + => new( + RootPath: rootPath, + RepositoryName: repositoryName, + AdapterKind: adapterKind, + TargetName: targetName, + TargetKind: targetKind, + Destination: destination, + SourcePath: sourcePath, + Status: status, + Summary: summary, + PublishedAtUtc: DateTimeOffset.UtcNow); + + public static ReleasePublishReceipt FailedPublishReceipt( + string rootPath, + string repositoryName, + string adapterKind, + string targetName, + string? destination, + string summary, + string? targetKind = null, + string? sourcePath = null) + => CreatePublishReceipt( + rootPath, + repositoryName, + adapterKind, + targetName, + string.IsNullOrWhiteSpace(targetKind) ? targetName : targetKind!, + destination, + ReleasePublishReceiptStatus.Failed, + summary, + sourcePath); + + public static ReleasePublishReceipt SkippedPublishReceipt( + string rootPath, + string repositoryName, + string adapterKind, + string targetName, + string? destination, + string summary, + string? targetKind = null, + string? sourcePath = null) + => CreatePublishReceipt( + rootPath, + repositoryName, + adapterKind, + targetName, + string.IsNullOrWhiteSpace(targetKind) ? targetName : targetKind!, + destination, + ReleasePublishReceiptStatus.Skipped, + summary, + sourcePath); + + public static ReleaseVerificationReceipt CreateVerificationReceipt( + ReleasePublishReceipt publishReceipt, + ReleaseVerificationReceiptStatus status, + string summary) + => new( + RootPath: publishReceipt.RootPath, + RepositoryName: publishReceipt.RepositoryName, + AdapterKind: publishReceipt.AdapterKind, + TargetName: publishReceipt.TargetName, + TargetKind: publishReceipt.TargetKind, + Destination: publishReceipt.Destination, + Status: status, + Summary: summary, + VerifiedAtUtc: DateTimeOffset.UtcNow); + + public static ReleaseVerificationReceipt FailedVerificationReceipt( + string rootPath, + string repositoryName, + string adapterKind, + string targetName, + string? destination, + string summary, + string? targetKind = null) + => new( + RootPath: rootPath, + RepositoryName: repositoryName, + AdapterKind: adapterKind, + TargetName: targetName, + TargetKind: string.IsNullOrWhiteSpace(targetKind) ? targetName : targetKind!, + Destination: destination, + Status: ReleaseVerificationReceiptStatus.Failed, + Summary: summary, + VerifiedAtUtc: DateTimeOffset.UtcNow); +} diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueRunner.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueRunner.cs index a453d117..178f2d6f 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueRunner.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueRunner.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using PowerForgeStudio.Domain.Publish; using PowerForgeStudio.Domain.Queue; using PowerForgeStudio.Domain.Signing; @@ -8,6 +7,14 @@ namespace PowerForgeStudio.Orchestrator.Queue; public sealed class ReleaseQueueRunner { + private readonly ReleaseQueueCheckpointSerializer _checkpointSerializer = new(); + private readonly ReleaseQueueItemTransitionFactory _itemTransitionFactory; + + public ReleaseQueueRunner() + { + _itemTransitionFactory = new ReleaseQueueItemTransitionFactory(_checkpointSerializer); + } + public ReleaseQueueTransitionResult AdvanceNextReadyItem(ReleaseQueueSession session) { ArgumentNullException.ThrowIfNull(session); @@ -29,27 +36,27 @@ public ReleaseQueueTransitionResult AdvanceNextReadyItem(ReleaseQueueSession ses switch (item.Stage) { case ReleaseQueueStage.Build: - updatedItem = item with { - Stage = ReleaseQueueStage.Sign, - Status = ReleaseQueueItemStatus.WaitingApproval, - Summary = "Build stage completed in orchestration mode. USB signing approval is now required.", - CheckpointKey = "sign.waiting.usb", - CheckpointStateJson = SerializeCheckpoint("Build", "Sign", timestamp), - UpdatedAtUtc = timestamp - }; + updatedItem = _itemTransitionFactory.CreateTransition( + item, + fromStage: "Build", + targetStage: ReleaseQueueStage.Sign, + targetStatus: ReleaseQueueItemStatus.WaitingApproval, + summary: "Build stage completed in orchestration mode. USB signing approval is now required.", + checkpointKey: "sign.waiting.usb", + timestamp: timestamp); message = $"Advanced {item.RepositoryName} from Build to Sign and paused for USB approval."; break; case ReleaseQueueStage.Sign: return new ReleaseQueueTransitionResult(session, false, $"{item.RepositoryName} is marked Sign/ReadyToRun, but signing requires the USB approval gate before execution can continue."); case ReleaseQueueStage.Publish: - updatedItem = item with { - Stage = ReleaseQueueStage.Verify, - Status = ReleaseQueueItemStatus.ReadyToRun, - Summary = "Publish step completed in orchestration mode. Verification is ready to run.", - CheckpointKey = "verify.ready", - CheckpointStateJson = SerializeCheckpoint("Publish", "Verify", timestamp), - UpdatedAtUtc = timestamp - }; + updatedItem = _itemTransitionFactory.CreateTransition( + item, + fromStage: "Publish", + targetStage: ReleaseQueueStage.Verify, + targetStatus: ReleaseQueueItemStatus.ReadyToRun, + summary: "Publish step completed in orchestration mode. Verification is ready to run.", + checkpointKey: "verify.ready", + timestamp: timestamp); message = $"Advanced {item.RepositoryName} from Publish to Verify."; break; case ReleaseQueueStage.Verify: @@ -74,16 +81,16 @@ public ReleaseQueueTransitionResult ApproveNextSigningGate(ReleaseQueueSession s return new ReleaseQueueTransitionResult(session, false, "No queue item is currently waiting on USB approval."); } - var item = nextEntry.Item; var timestamp = DateTimeOffset.UtcNow; - var updatedItem = item with { - Stage = ReleaseQueueStage.Publish, - Status = ReleaseQueueItemStatus.ReadyToRun, - Summary = "USB approval recorded. Publish stage is ready to run.", - CheckpointKey = "publish.ready", - CheckpointStateJson = SerializeCheckpoint("Sign", "Publish", timestamp), - UpdatedAtUtc = timestamp - }; + var item = nextEntry.Item; + var updatedItem = _itemTransitionFactory.CreateTransition( + item, + fromStage: "Sign", + targetStage: ReleaseQueueStage.Publish, + targetStatus: ReleaseQueueItemStatus.ReadyToRun, + summary: "USB approval recorded. Publish stage is ready to run.", + checkpointKey: "publish.ready", + timestamp: timestamp); return BuildResult(session, nextEntry.Index, updatedItem, $"USB approval recorded for {item.RepositoryName}. Publish is now ready.", timestamp); } @@ -94,9 +101,7 @@ public ReleaseQueueTransitionResult CompleteBuild(ReleaseQueueSession session, s ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); ArgumentNullException.ThrowIfNull(buildResult); - var entry = session.Items - .Select((item, index) => new QueueLookupEntry(item, index)) - .FirstOrDefault(candidate => string.Equals(candidate.Item.RootPath, rootPath, StringComparison.OrdinalIgnoreCase)); + var entry = FindEntry(session, rootPath); if (entry is null) { @@ -104,14 +109,14 @@ public ReleaseQueueTransitionResult CompleteBuild(ReleaseQueueSession session, s } var timestamp = DateTimeOffset.UtcNow; - var updatedItem = entry.Item with { - Stage = ReleaseQueueStage.Sign, - Status = ReleaseQueueItemStatus.WaitingApproval, - Summary = buildResult.Summary, - CheckpointKey = "sign.waiting.usb", - CheckpointStateJson = JsonSerializer.Serialize(buildResult), - UpdatedAtUtc = timestamp - }; + var updatedItem = _itemTransitionFactory.CreateCheckpointUpdate( + entry.Item, + targetStage: ReleaseQueueStage.Sign, + targetStatus: ReleaseQueueItemStatus.WaitingApproval, + summary: buildResult.Summary, + checkpointKey: "sign.waiting.usb", + checkpoint: buildResult, + timestamp: timestamp); return BuildResult(session, entry.Index, updatedItem, $"Build completed for {entry.Item.RepositoryName}. USB signing approval is now required.", timestamp); } @@ -122,9 +127,7 @@ public ReleaseQueueTransitionResult FailBuild(ReleaseQueueSession session, strin ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); ArgumentNullException.ThrowIfNull(buildResult); - var entry = session.Items - .Select((item, index) => new QueueLookupEntry(item, index)) - .FirstOrDefault(candidate => string.Equals(candidate.Item.RootPath, rootPath, StringComparison.OrdinalIgnoreCase)); + var entry = FindEntry(session, rootPath); if (entry is null) { @@ -132,14 +135,14 @@ public ReleaseQueueTransitionResult FailBuild(ReleaseQueueSession session, strin } var timestamp = DateTimeOffset.UtcNow; - var updatedItem = entry.Item with { - Stage = ReleaseQueueStage.Build, - Status = ReleaseQueueItemStatus.Failed, - Summary = buildResult.Summary, - CheckpointKey = "build.failed", - CheckpointStateJson = JsonSerializer.Serialize(buildResult), - UpdatedAtUtc = timestamp - }; + var updatedItem = _itemTransitionFactory.CreateCheckpointUpdate( + entry.Item, + targetStage: ReleaseQueueStage.Build, + targetStatus: ReleaseQueueItemStatus.Failed, + summary: buildResult.Summary, + checkpointKey: "build.failed", + checkpoint: buildResult, + timestamp: timestamp); return BuildResult(session, entry.Index, updatedItem, $"Build failed for {entry.Item.RepositoryName}. Review the captured queue state before retrying.", timestamp); } @@ -150,9 +153,7 @@ public ReleaseQueueTransitionResult CompleteSigning(ReleaseQueueSession session, ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); ArgumentNullException.ThrowIfNull(signingResult); - var entry = session.Items - .Select((item, index) => new QueueLookupEntry(item, index)) - .FirstOrDefault(candidate => string.Equals(candidate.Item.RootPath, rootPath, StringComparison.OrdinalIgnoreCase)); + var entry = FindEntry(session, rootPath); if (entry is null) { @@ -160,14 +161,14 @@ public ReleaseQueueTransitionResult CompleteSigning(ReleaseQueueSession session, } var timestamp = DateTimeOffset.UtcNow; - var updatedItem = entry.Item with { - Stage = ReleaseQueueStage.Publish, - Status = ReleaseQueueItemStatus.ReadyToRun, - Summary = signingResult.Summary, - CheckpointKey = "publish.ready", - CheckpointStateJson = JsonSerializer.Serialize(signingResult), - UpdatedAtUtc = timestamp - }; + var updatedItem = _itemTransitionFactory.CreateCheckpointUpdate( + entry.Item, + targetStage: ReleaseQueueStage.Publish, + targetStatus: ReleaseQueueItemStatus.ReadyToRun, + summary: signingResult.Summary, + checkpointKey: "publish.ready", + checkpoint: signingResult, + timestamp: timestamp); return BuildResult(session, entry.Index, updatedItem, $"Signing completed for {entry.Item.RepositoryName}. Publish is now ready.", timestamp); } @@ -178,9 +179,7 @@ public ReleaseQueueTransitionResult FailSigning(ReleaseQueueSession session, str ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); ArgumentNullException.ThrowIfNull(signingResult); - var entry = session.Items - .Select((item, index) => new QueueLookupEntry(item, index)) - .FirstOrDefault(candidate => string.Equals(candidate.Item.RootPath, rootPath, StringComparison.OrdinalIgnoreCase)); + var entry = FindEntry(session, rootPath); if (entry is null) { @@ -188,14 +187,14 @@ public ReleaseQueueTransitionResult FailSigning(ReleaseQueueSession session, str } var timestamp = DateTimeOffset.UtcNow; - var updatedItem = entry.Item with { - Stage = ReleaseQueueStage.Sign, - Status = ReleaseQueueItemStatus.Failed, - Summary = signingResult.Summary, - CheckpointKey = "sign.failed", - CheckpointStateJson = JsonSerializer.Serialize(signingResult), - UpdatedAtUtc = timestamp - }; + var updatedItem = _itemTransitionFactory.CreateCheckpointUpdate( + entry.Item, + targetStage: ReleaseQueueStage.Sign, + targetStatus: ReleaseQueueItemStatus.Failed, + summary: signingResult.Summary, + checkpointKey: "sign.failed", + checkpoint: signingResult, + timestamp: timestamp); return BuildResult(session, entry.Index, updatedItem, $"Signing failed for {entry.Item.RepositoryName}. Review signing receipts before retrying.", timestamp); } @@ -206,9 +205,7 @@ public ReleaseQueueTransitionResult CompletePublish(ReleaseQueueSession session, ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); ArgumentNullException.ThrowIfNull(publishResult); - var entry = session.Items - .Select((item, index) => new QueueLookupEntry(item, index)) - .FirstOrDefault(candidate => string.Equals(candidate.Item.RootPath, rootPath, StringComparison.OrdinalIgnoreCase)); + var entry = FindEntry(session, rootPath); if (entry is null) { @@ -216,14 +213,14 @@ public ReleaseQueueTransitionResult CompletePublish(ReleaseQueueSession session, } var timestamp = DateTimeOffset.UtcNow; - var updatedItem = entry.Item with { - Stage = ReleaseQueueStage.Verify, - Status = ReleaseQueueItemStatus.ReadyToRun, - Summary = publishResult.Summary, - CheckpointKey = "verify.ready", - CheckpointStateJson = JsonSerializer.Serialize(publishResult), - UpdatedAtUtc = timestamp - }; + var updatedItem = _itemTransitionFactory.CreateCheckpointUpdate( + entry.Item, + targetStage: ReleaseQueueStage.Verify, + targetStatus: ReleaseQueueItemStatus.ReadyToRun, + summary: publishResult.Summary, + checkpointKey: "verify.ready", + checkpoint: publishResult, + timestamp: timestamp); return BuildResult(session, entry.Index, updatedItem, $"Publish completed for {entry.Item.RepositoryName}. Verification is now ready.", timestamp); } @@ -234,9 +231,7 @@ public ReleaseQueueTransitionResult FailPublish(ReleaseQueueSession session, str ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); ArgumentNullException.ThrowIfNull(publishResult); - var entry = session.Items - .Select((item, index) => new QueueLookupEntry(item, index)) - .FirstOrDefault(candidate => string.Equals(candidate.Item.RootPath, rootPath, StringComparison.OrdinalIgnoreCase)); + var entry = FindEntry(session, rootPath); if (entry is null) { @@ -244,14 +239,14 @@ public ReleaseQueueTransitionResult FailPublish(ReleaseQueueSession session, str } var timestamp = DateTimeOffset.UtcNow; - var updatedItem = entry.Item with { - Stage = ReleaseQueueStage.Publish, - Status = ReleaseQueueItemStatus.Failed, - Summary = publishResult.Summary, - CheckpointKey = "publish.failed", - CheckpointStateJson = JsonSerializer.Serialize(publishResult), - UpdatedAtUtc = timestamp - }; + var updatedItem = _itemTransitionFactory.CreateCheckpointUpdate( + entry.Item, + targetStage: ReleaseQueueStage.Publish, + targetStatus: ReleaseQueueItemStatus.Failed, + summary: publishResult.Summary, + checkpointKey: "publish.failed", + checkpoint: publishResult, + timestamp: timestamp); return BuildResult(session, entry.Index, updatedItem, $"Publish failed for {entry.Item.RepositoryName}. Review publish receipts before retrying.", timestamp); } @@ -262,9 +257,7 @@ public ReleaseQueueTransitionResult CompleteVerification(ReleaseQueueSession ses ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); ArgumentNullException.ThrowIfNull(verificationResult); - var entry = session.Items - .Select((item, index) => new QueueLookupEntry(item, index)) - .FirstOrDefault(candidate => string.Equals(candidate.Item.RootPath, rootPath, StringComparison.OrdinalIgnoreCase)); + var entry = FindEntry(session, rootPath); if (entry is null) { @@ -272,14 +265,14 @@ public ReleaseQueueTransitionResult CompleteVerification(ReleaseQueueSession ses } var timestamp = DateTimeOffset.UtcNow; - var updatedItem = entry.Item with { - Stage = ReleaseQueueStage.Completed, - Status = ReleaseQueueItemStatus.Succeeded, - Summary = verificationResult.Summary, - CheckpointKey = "completed", - CheckpointStateJson = JsonSerializer.Serialize(verificationResult), - UpdatedAtUtc = timestamp - }; + var updatedItem = _itemTransitionFactory.CreateCheckpointUpdate( + entry.Item, + targetStage: ReleaseQueueStage.Completed, + targetStatus: ReleaseQueueItemStatus.Succeeded, + summary: verificationResult.Summary, + checkpointKey: "completed", + checkpoint: verificationResult, + timestamp: timestamp); return BuildResult(session, entry.Index, updatedItem, $"Verification completed for {entry.Item.RepositoryName}. Queue item is now closed.", timestamp); } @@ -290,9 +283,7 @@ public ReleaseQueueTransitionResult FailVerification(ReleaseQueueSession session ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); ArgumentNullException.ThrowIfNull(verificationResult); - var entry = session.Items - .Select((item, index) => new QueueLookupEntry(item, index)) - .FirstOrDefault(candidate => string.Equals(candidate.Item.RootPath, rootPath, StringComparison.OrdinalIgnoreCase)); + var entry = FindEntry(session, rootPath); if (entry is null) { @@ -300,14 +291,14 @@ public ReleaseQueueTransitionResult FailVerification(ReleaseQueueSession session } var timestamp = DateTimeOffset.UtcNow; - var updatedItem = entry.Item with { - Stage = ReleaseQueueStage.Verify, - Status = ReleaseQueueItemStatus.Failed, - Summary = verificationResult.Summary, - CheckpointKey = "verify.failed", - CheckpointStateJson = JsonSerializer.Serialize(verificationResult), - UpdatedAtUtc = timestamp - }; + var updatedItem = _itemTransitionFactory.CreateCheckpointUpdate( + entry.Item, + targetStage: ReleaseQueueStage.Verify, + targetStatus: ReleaseQueueItemStatus.Failed, + summary: verificationResult.Summary, + checkpointKey: "verify.failed", + checkpoint: verificationResult, + timestamp: timestamp); return BuildResult(session, entry.Index, updatedItem, $"Verification failed for {entry.Item.RepositoryName}. Review verification receipts before retrying.", timestamp); } @@ -373,10 +364,7 @@ public ReleaseQueueTransitionResult RetryFailedItems(ReleaseQueueSession session return new ReleaseQueueTransitionResult(session, false, "Matching failed queue items were found, but none could be safely rearmed."); } - var updatedSession = session with { - Items = items, - Summary = BuildSummary(items) - }; + var updatedSession = ReleaseQueueSessionFactory.WithItems(session, items); return new ReleaseQueueTransitionResult(updatedSession, true, $"Rearmed {retried} failed queue item(s) for retry."); } @@ -390,42 +378,19 @@ private static ReleaseQueueTransitionResult BuildResult( { var items = session.Items.ToList(); items[index] = updatedItem; - var updatedSession = session with { - Items = items, - Summary = BuildSummary(items) - }; + var updatedSession = ReleaseQueueSessionFactory.WithItems(session, items); return new ReleaseQueueTransitionResult(updatedSession, true, message); } - private static ReleaseQueueSummary BuildSummary(IReadOnlyList items) - { - return new ReleaseQueueSummary( - TotalItems: items.Count, - BuildReadyItems: items.Count(item => item.Stage == ReleaseQueueStage.Build && item.Status == ReleaseQueueItemStatus.ReadyToRun), - PreparePendingItems: items.Count(item => item.Stage == ReleaseQueueStage.Prepare && item.Status == ReleaseQueueItemStatus.Pending), - WaitingApprovalItems: items.Count(item => item.Status == ReleaseQueueItemStatus.WaitingApproval), - BlockedItems: items.Count(item => item.Status == ReleaseQueueItemStatus.Blocked || item.Status == ReleaseQueueItemStatus.Failed), - VerificationReadyItems: items.Count(item => item.Stage == ReleaseQueueStage.Verify && item.Status == ReleaseQueueItemStatus.ReadyToRun)); - } - - private static string SerializeCheckpoint(string fromStage, string toStage, DateTimeOffset timestamp) - { - return JsonSerializer.Serialize(new Dictionary { - ["from"] = fromStage, - ["to"] = toStage, - ["updatedAtUtc"] = timestamp.ToString("O") - }); - } - - private static ReleaseQueueTransitionResult RetryBuild(ReleaseQueueSession session, QueueLookupEntry entry, DateTimeOffset timestamp) + private ReleaseQueueTransitionResult RetryBuild(ReleaseQueueSession session, QueueLookupEntry entry, DateTimeOffset timestamp) { var updatedItem = BuildRetryItem(entry.Item, timestamp)!; return BuildResult(session, entry.Index, updatedItem, $"Build retry armed for {entry.Item.RepositoryName}.", timestamp); } - private static ReleaseQueueTransitionResult RetrySigning(ReleaseQueueSession session, QueueLookupEntry entry, DateTimeOffset timestamp) + private ReleaseQueueTransitionResult RetrySigning(ReleaseQueueSession session, QueueLookupEntry entry, DateTimeOffset timestamp) { var updatedItem = BuildRetryItem(entry.Item, timestamp); if (updatedItem is null) @@ -436,7 +401,7 @@ private static ReleaseQueueTransitionResult RetrySigning(ReleaseQueueSession ses return BuildResult(session, entry.Index, updatedItem, $"Signing retry armed for {entry.Item.RepositoryName}. USB approval is required again.", timestamp); } - private static ReleaseQueueTransitionResult RetryPublish(ReleaseQueueSession session, QueueLookupEntry entry, DateTimeOffset timestamp) + private ReleaseQueueTransitionResult RetryPublish(ReleaseQueueSession session, QueueLookupEntry entry, DateTimeOffset timestamp) { var updatedItem = BuildRetryItem(entry.Item, timestamp); if (updatedItem is null) @@ -447,7 +412,7 @@ private static ReleaseQueueTransitionResult RetryPublish(ReleaseQueueSession ses return BuildResult(session, entry.Index, updatedItem, $"Publish retry armed for {entry.Item.RepositoryName}.", timestamp); } - private static ReleaseQueueTransitionResult RetryVerification(ReleaseQueueSession session, QueueLookupEntry entry, DateTimeOffset timestamp) + private ReleaseQueueTransitionResult RetryVerification(ReleaseQueueSession session, QueueLookupEntry entry, DateTimeOffset timestamp) { var updatedItem = BuildRetryItem(entry.Item, timestamp); if (updatedItem is null) @@ -458,92 +423,82 @@ private static ReleaseQueueTransitionResult RetryVerification(ReleaseQueueSessio return BuildResult(session, entry.Index, updatedItem, $"Verification retry armed for {entry.Item.RepositoryName}.", timestamp); } - private static ReleaseQueueItem? BuildRetryItem(ReleaseQueueItem item, DateTimeOffset timestamp) + private ReleaseQueueItem? BuildRetryItem(ReleaseQueueItem item, DateTimeOffset timestamp) => item.Stage switch { - ReleaseQueueStage.Build => item with { - Stage = ReleaseQueueStage.Build, - Status = ReleaseQueueItemStatus.ReadyToRun, - Summary = "Build retry armed. The item is ready to rerun the build stage.", - CheckpointKey = "build.ready", - CheckpointStateJson = null, - UpdatedAtUtc = timestamp - }, + ReleaseQueueStage.Build => _itemTransitionFactory.CreateStateUpdate( + item, + targetStage: ReleaseQueueStage.Build, + targetStatus: ReleaseQueueItemStatus.ReadyToRun, + summary: "Build retry armed. The item is ready to rerun the build stage.", + checkpointKey: "build.ready", + checkpointStateJson: null, + timestamp: timestamp), ReleaseQueueStage.Sign => BuildSigningRetryItem(item, timestamp), ReleaseQueueStage.Publish => BuildPublishRetryItem(item, timestamp), ReleaseQueueStage.Verify => BuildVerificationRetryItem(item, timestamp), _ => null }; - private static ReleaseQueueItem? BuildSigningRetryItem(ReleaseQueueItem item, DateTimeOffset timestamp) + private ReleaseQueueItem? BuildSigningRetryItem(ReleaseQueueItem item, DateTimeOffset timestamp) { - var signingResult = TryDeserialize(item.CheckpointStateJson); + var signingResult = _checkpointSerializer.TryDeserialize(item.CheckpointStateJson); if (string.IsNullOrWhiteSpace(signingResult?.SourceCheckpointStateJson)) { return null; } - return item with { - Stage = ReleaseQueueStage.Sign, - Status = ReleaseQueueItemStatus.WaitingApproval, - Summary = "Signing retry armed. USB approval is required again before signing resumes.", - CheckpointKey = "sign.waiting.usb", - CheckpointStateJson = signingResult.SourceCheckpointStateJson, - UpdatedAtUtc = timestamp - }; + return _itemTransitionFactory.CreateStateUpdate( + item, + targetStage: ReleaseQueueStage.Sign, + targetStatus: ReleaseQueueItemStatus.WaitingApproval, + summary: "Signing retry armed. USB approval is required again before signing resumes.", + checkpointKey: "sign.waiting.usb", + checkpointStateJson: signingResult.SourceCheckpointStateJson, + timestamp: timestamp); } - private static ReleaseQueueItem? BuildPublishRetryItem(ReleaseQueueItem item, DateTimeOffset timestamp) + private ReleaseQueueItem? BuildPublishRetryItem(ReleaseQueueItem item, DateTimeOffset timestamp) { - var publishResult = TryDeserialize(item.CheckpointStateJson); + var publishResult = _checkpointSerializer.TryDeserialize(item.CheckpointStateJson); if (string.IsNullOrWhiteSpace(publishResult?.SourceCheckpointStateJson)) { return null; } - return item with { - Stage = ReleaseQueueStage.Publish, - Status = ReleaseQueueItemStatus.ReadyToRun, - Summary = "Publish retry armed. The item is ready to rerun the publish stage.", - CheckpointKey = "publish.ready", - CheckpointStateJson = publishResult.SourceCheckpointStateJson, - UpdatedAtUtc = timestamp - }; + return _itemTransitionFactory.CreateStateUpdate( + item, + targetStage: ReleaseQueueStage.Publish, + targetStatus: ReleaseQueueItemStatus.ReadyToRun, + summary: "Publish retry armed. The item is ready to rerun the publish stage.", + checkpointKey: "publish.ready", + checkpointStateJson: publishResult.SourceCheckpointStateJson, + timestamp: timestamp); } - private static ReleaseQueueItem? BuildVerificationRetryItem(ReleaseQueueItem item, DateTimeOffset timestamp) + private ReleaseQueueItem? BuildVerificationRetryItem(ReleaseQueueItem item, DateTimeOffset timestamp) { - var verificationResult = TryDeserialize(item.CheckpointStateJson); + var verificationResult = _checkpointSerializer.TryDeserialize(item.CheckpointStateJson); if (string.IsNullOrWhiteSpace(verificationResult?.SourceCheckpointStateJson)) { return null; } - return item with { - Stage = ReleaseQueueStage.Verify, - Status = ReleaseQueueItemStatus.ReadyToRun, - Summary = "Verification retry armed. The item is ready to rerun the verification stage.", - CheckpointKey = "verify.ready", - CheckpointStateJson = verificationResult.SourceCheckpointStateJson, - UpdatedAtUtc = timestamp - }; + return _itemTransitionFactory.CreateStateUpdate( + item, + targetStage: ReleaseQueueStage.Verify, + targetStatus: ReleaseQueueItemStatus.ReadyToRun, + summary: "Verification retry armed. The item is ready to rerun the verification stage.", + checkpointKey: "verify.ready", + checkpointStateJson: verificationResult.SourceCheckpointStateJson, + timestamp: timestamp); } - private static T? TryDeserialize(string? json) + private static QueueLookupEntry? FindEntry(ReleaseQueueSession session, string rootPath) { - if (string.IsNullOrWhiteSpace(json)) - { - return default; - } - - try - { - return JsonSerializer.Deserialize(json); - } - catch - { - return default; - } + return session.Items + .Select((item, index) => new QueueLookupEntry(item, index)) + .FirstOrDefault(candidate => string.Equals(candidate.Item.RootPath, rootPath, StringComparison.OrdinalIgnoreCase)); } private sealed record QueueLookupEntry(ReleaseQueueItem Item, int Index); diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueSessionFactory.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueSessionFactory.cs new file mode 100644 index 00000000..ba6a2f30 --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueSessionFactory.cs @@ -0,0 +1,40 @@ +using PowerForgeStudio.Domain.Queue; + +namespace PowerForgeStudio.Orchestrator.Queue; + +public static class ReleaseQueueSessionFactory +{ + public static ReleaseQueueSession Create( + string workspaceRoot, + IReadOnlyList items, + DateTimeOffset createdAtUtc, + string? scopeKey = null, + string? scopeDisplayName = null, + string? sessionId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspaceRoot); + ArgumentNullException.ThrowIfNull(items); + + return new ReleaseQueueSession( + SessionId: string.IsNullOrWhiteSpace(sessionId) ? Guid.NewGuid().ToString("N") : sessionId, + WorkspaceRoot: workspaceRoot, + CreatedAtUtc: createdAtUtc, + Summary: ReleaseQueueSummaryFactory.Create(items), + Items: items, + ScopeKey: scopeKey, + ScopeDisplayName: scopeDisplayName); + } + + public static ReleaseQueueSession WithItems( + ReleaseQueueSession session, + IReadOnlyList items) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(items); + + return session with { + Items = items, + Summary = ReleaseQueueSummaryFactory.Create(items) + }; + } +} diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueSummaryFactory.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueSummaryFactory.cs new file mode 100644 index 00000000..eefea8ce --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueSummaryFactory.cs @@ -0,0 +1,19 @@ +using PowerForgeStudio.Domain.Queue; + +namespace PowerForgeStudio.Orchestrator.Queue; + +public static class ReleaseQueueSummaryFactory +{ + public static ReleaseQueueSummary Create(IReadOnlyList items) + { + ArgumentNullException.ThrowIfNull(items); + + return new ReleaseQueueSummary( + TotalItems: items.Count, + BuildReadyItems: items.Count(item => item.Stage == ReleaseQueueStage.Build && item.Status == ReleaseQueueItemStatus.ReadyToRun), + PreparePendingItems: items.Count(item => item.Stage == ReleaseQueueStage.Prepare && item.Status == ReleaseQueueItemStatus.Pending), + WaitingApprovalItems: items.Count(item => item.Status == ReleaseQueueItemStatus.WaitingApproval), + BlockedItems: items.Count(item => item.Status == ReleaseQueueItemStatus.Blocked || item.Status == ReleaseQueueItemStatus.Failed), + VerificationReadyItems: items.Count(item => item.Stage == ReleaseQueueStage.Verify && item.Status == ReleaseQueueItemStatus.ReadyToRun)); + } +} diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueTargetProjectionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueTargetProjectionService.cs new file mode 100644 index 00000000..1e0f8a0e --- /dev/null +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseQueueTargetProjectionService.cs @@ -0,0 +1,35 @@ +using PowerForgeStudio.Domain.Queue; + +namespace PowerForgeStudio.Orchestrator.Queue; + +public sealed class ReleaseQueueTargetProjectionService +{ + public IReadOnlyList BuildTargets( + IEnumerable queueItems, + ReleaseQueueStage stage, + Func tryReadCheckpoint, + Func> projectTargets, + Func distinctKeySelector) + { + ArgumentNullException.ThrowIfNull(queueItems); + ArgumentNullException.ThrowIfNull(tryReadCheckpoint); + ArgumentNullException.ThrowIfNull(projectTargets); + ArgumentNullException.ThrowIfNull(distinctKeySelector); + + var targets = new List(); + foreach (var item in queueItems.Where(candidate => candidate.Stage == stage && candidate.Status == ReleaseQueueItemStatus.ReadyToRun)) + { + var checkpoint = tryReadCheckpoint(item); + if (checkpoint is null) + { + continue; + } + + targets.AddRange(projectTargets(item, checkpoint)); + } + + return targets + .DistinctBy(distinctKeySelector, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseSigningExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseSigningExecutionService.cs index dced8700..fadcc7db 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseSigningExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseSigningExecutionService.cs @@ -1,18 +1,42 @@ -using System.Diagnostics; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; +using PowerForge; using PowerForgeStudio.Domain.Queue; using PowerForgeStudio.Domain.Signing; -using PowerForgeStudio.Orchestrator.Portfolio; -using PowerForgeStudio.Orchestrator.PowerShell; +using PowerForgeStudio.Orchestrator.Host; namespace PowerForgeStudio.Orchestrator.Queue; public sealed class ReleaseSigningExecutionService : IReleaseSigningExecutionService { private static readonly string[] AuthenticodeDirectoryIncludes = ["*.ps1", "*.psm1", "*.psd1", "*.dll", "*.exe", "*.cat"]; - private readonly ReleaseBuildCheckpointReader _checkpointReader = new(); - private readonly PowerShellCommandRunner _powerShellCommandRunner = new(); + private readonly ReleaseBuildCheckpointReader _checkpointReader; + private readonly ReleaseSigningHostSettingsResolver _settingsResolver; + private readonly CertificateFingerprintResolver _certificateFingerprintResolver; + private readonly Func> _signAuthenticodeAsync; + private readonly Func> _signNuGetPackageAsync; + + public ReleaseSigningExecutionService() + : this( + new ReleaseBuildCheckpointReader(), + new ReleaseSigningHostSettingsResolver(PowerForgeStudioHostPaths.ResolvePSPublishModulePath), + new CertificateFingerprintResolver(), + (request, cancellationToken) => new AuthenticodeSigningHostService().SignAsync(request, cancellationToken), + (request, cancellationToken) => new DotNetNuGetClient().SignPackageAsync(request, cancellationToken)) + { + } + + internal ReleaseSigningExecutionService( + ReleaseBuildCheckpointReader checkpointReader, + ReleaseSigningHostSettingsResolver settingsResolver, + CertificateFingerprintResolver certificateFingerprintResolver, + Func> signAuthenticodeAsync, + Func> signNuGetPackageAsync) + { + _checkpointReader = checkpointReader; + _settingsResolver = settingsResolver; + _certificateFingerprintResolver = certificateFingerprintResolver; + _signAuthenticodeAsync = signAuthenticodeAsync; + _signNuGetPackageAsync = signNuGetPackageAsync; + } public async Task ExecuteAsync(ReleaseQueueItem queueItem, CancellationToken cancellationToken = default) { @@ -29,7 +53,7 @@ public async Task ExecuteAsync(ReleaseQueueItem q Receipts: []); } - var settings = ResolveSettings(); + var settings = _settingsResolver.Resolve(); if (!settings.IsConfigured) { return new ReleaseSigningExecutionResult( @@ -73,7 +97,7 @@ public async Task ExecuteAsync(ReleaseQueueItem q private async Task SignArtifactAsync( string rootPath, ReleaseSigningArtifact artifact, - SigningSettings settings, + ReleaseSigningHostSettings settings, CancellationToken cancellationToken) { var timestamp = DateTimeOffset.UtcNow; @@ -139,7 +163,7 @@ private async Task SignWithRegisterCertificateAsync( ReleaseSigningArtifact artifact, string signingPath, IReadOnlyList includePatterns, - SigningSettings settings, + ReleaseSigningHostSettings settings, DateTimeOffset timestamp, CancellationToken cancellationToken) { @@ -148,9 +172,15 @@ private async Task SignWithRegisterCertificateAsync( return FailedReceipt(rootPath, artifact, $"Signing path '{signingPath}' was not found.", timestamp); } - var script = BuildRegisterCertificateScript(signingPath, includePatterns, settings); - var execution = await _powerShellCommandRunner.RunCommandAsync(signingPath, script, cancellationToken); - var succeeded = execution.ExitCode == 0; + var execution = await _signAuthenticodeAsync(new AuthenticodeSigningHostRequest { + SigningPath = signingPath, + IncludePatterns = includePatterns, + ModulePath = settings.ModulePath, + Thumbprint = settings.Thumbprint!, + StoreName = settings.StoreName, + TimeStampServer = settings.TimeStampServer + }, cancellationToken); + var succeeded = execution.Succeeded; var detail = succeeded ? "Authenticode signing completed." : FirstLine(execution.StandardError) ?? FirstLine(execution.StandardOutput) ?? "Register-Certificate failed."; @@ -166,64 +196,34 @@ private async Task SignWithRegisterCertificateAsync( SignedAtUtc: timestamp); } - private static string BuildRegisterCertificateScript(string signingPath, IReadOnlyList includePatterns, SigningSettings settings) - { - var includes = string.Join(", ", includePatterns.Select(pattern => PowerShellScriptEscaping.QuoteLiteral(pattern))); - return string.Join("; ", new[] { - "$ErrorActionPreference = 'Stop'", - BuildModuleImportClause(settings.ModulePath), - $"Register-Certificate -Path {PowerShellScriptEscaping.QuoteLiteral(signingPath)} -LocalStore {settings.StoreName} -Thumbprint {PowerShellScriptEscaping.QuoteLiteral(settings.Thumbprint!)} -TimeStampServer {PowerShellScriptEscaping.QuoteLiteral(settings.TimeStampServer)} -Include @({includes}) -Confirm:$false -WarningAction Stop -ErrorAction Stop | Out-Null" - }); - } - - private async Task<(bool Succeeded, string? ErrorMessage)> SignNuGetPackageAsync(ReleaseSigningArtifact artifact, SigningSettings settings, CancellationToken cancellationToken) + private async Task<(bool Succeeded, string? ErrorMessage)> SignNuGetPackageAsync(ReleaseSigningArtifact artifact, ReleaseSigningHostSettings settings, CancellationToken cancellationToken) { if (!File.Exists(artifact.ArtifactPath)) { return (false, $"Package '{artifact.ArtifactPath}' was not found."); } - var sha256 = TryGetCertificateSha256(settings.Thumbprint!, settings.StoreName); + var sha256 = _certificateFingerprintResolver.ResolveSha256(settings.Thumbprint!, settings.StoreName); if (string.IsNullOrWhiteSpace(sha256)) { return (false, $"Unable to resolve SHA256 certificate fingerprint for thumbprint {settings.Thumbprint}."); } - using var process = new Process(); - process.StartInfo = new ProcessStartInfo { - FileName = "dotnet", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - process.StartInfo.ArgumentList.Add("nuget"); - process.StartInfo.ArgumentList.Add("sign"); - process.StartInfo.ArgumentList.Add(artifact.ArtifactPath); - process.StartInfo.ArgumentList.Add("--certificate-fingerprint"); - process.StartInfo.ArgumentList.Add(sha256); - process.StartInfo.ArgumentList.Add("--certificate-store-location"); - process.StartInfo.ArgumentList.Add(settings.StoreName); - process.StartInfo.ArgumentList.Add("--certificate-store-name"); - process.StartInfo.ArgumentList.Add("My"); - process.StartInfo.ArgumentList.Add("--timestamper"); - process.StartInfo.ArgumentList.Add(settings.TimeStampServer); - process.StartInfo.ArgumentList.Add("--overwrite"); + var result = await _signNuGetPackageAsync( + new DotNetNuGetSignRequest( + packagePath: artifact.ArtifactPath, + certificateFingerprint: sha256, + certificateStoreLocation: settings.StoreName, + timeStampServer: settings.TimeStampServer, + workingDirectory: Path.GetDirectoryName(artifact.ArtifactPath)), + cancellationToken).ConfigureAwait(false); - process.Start(); - var stdOutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var stdErrTask = process.StandardError.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken); - var stdOut = await stdOutTask; - var stdErr = await stdErrTask; - - if (process.ExitCode == 0) + if (result.Succeeded) { return (true, null); } - var error = FirstLine(stdErr) ?? FirstLine(stdOut) ?? $"dotnet nuget sign failed with exit code {process.ExitCode}."; - return (false, error); + return (false, result.ErrorMessage); } private static bool IsAuthenticodeFile(string extension) @@ -251,68 +251,6 @@ private static ReleaseSigningReceipt FailedReceipt(string rootPath, ReleaseSigni SignedAtUtc: timestamp); } - private static SigningSettings ResolveSettings() - { - var thumbprint = Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_SIGN_THUMBPRINT"); - var storeName = Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_SIGN_STORE"); - var timeStampServer = Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_SIGN_TIMESTAMP_URL"); - var modulePath = Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_PSPUBLISHMODULE_PATH"); - - modulePath = string.IsNullOrWhiteSpace(modulePath) - ? PSPublishModuleLocator.ResolveModulePath() - : modulePath; - - if (string.IsNullOrWhiteSpace(timeStampServer)) - { - timeStampServer = "http://timestamp.digicert.com"; - } - - if (string.IsNullOrWhiteSpace(storeName)) - { - storeName = "CurrentUser"; - } - - if (string.IsNullOrWhiteSpace(thumbprint)) - { - return new SigningSettings(false, null, storeName, timeStampServer, modulePath, "Signing is not configured. Set RELEASE_OPS_STUDIO_SIGN_THUMBPRINT first."); - } - - return new SigningSettings(true, thumbprint, storeName, timeStampServer, modulePath, null); - } - - private static string? TryGetCertificateSha256(string thumbprint, string storeName) - { - try - { - var location = string.Equals(storeName, "LocalMachine", StringComparison.OrdinalIgnoreCase) - ? StoreLocation.LocalMachine - : StoreLocation.CurrentUser; - using var store = new X509Store(StoreName.My, location); - store.Open(OpenFlags.ReadOnly); - var normalized = NormalizeThumbprint(thumbprint); - var certificate = store.Certificates.Cast() - .FirstOrDefault(candidate => NormalizeThumbprint(candidate.Thumbprint) == normalized); - return certificate?.GetCertHashString(HashAlgorithmName.SHA256); - } - catch - { - return null; - } - } - - private static string NormalizeThumbprint(string? thumbprint) - => (thumbprint ?? string.Empty).Replace(" ", string.Empty).ToUpperInvariant(); - - private static string BuildModuleImportClause(string modulePath) - { - if (File.Exists(modulePath)) - { - return $"try {{ Import-Module {PowerShellScriptEscaping.QuoteLiteral(modulePath)} -Force -ErrorAction Stop }} catch {{ Import-Module PSPublishModule -Force -ErrorAction Stop }}"; - } - - return "Import-Module PSPublishModule -Force -ErrorAction Stop"; - } - private static string? FirstLine(string? value) { if (string.IsNullOrWhiteSpace(value)) @@ -322,12 +260,4 @@ private static string BuildModuleImportClause(string modulePath) return value.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); } - - private sealed record SigningSettings( - bool IsConfigured, - string? Thumbprint, - string StoreName, - string TimeStampServer, - string ModulePath, - string? MissingConfigurationMessage); } diff --git a/PowerForgeStudio.Orchestrator/Queue/ReleaseVerificationExecutionService.cs b/PowerForgeStudio.Orchestrator/Queue/ReleaseVerificationExecutionService.cs index 1cea09bb..d81ae195 100644 --- a/PowerForgeStudio.Orchestrator/Queue/ReleaseVerificationExecutionService.cs +++ b/PowerForgeStudio.Orchestrator/Queue/ReleaseVerificationExecutionService.cs @@ -1,95 +1,69 @@ -using System.IO.Compression; -using System.Net; using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; +using PowerForge; using PowerForgeStudio.Domain.Publish; using PowerForgeStudio.Domain.Queue; using PowerForgeStudio.Domain.Verification; -using PowerForgeStudio.Orchestrator.Portfolio; -using PowerForgeStudio.Orchestrator.PowerShell; namespace PowerForgeStudio.Orchestrator.Queue; public sealed class ReleaseVerificationExecutionService : IReleaseVerificationExecutionService, IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { - PropertyNameCaseInsensitive = true - }; - - private readonly HttpClient _httpClient; - private readonly PowerShellCommandRunner _powerShellCommandRunner = new(); - private readonly Func> _runPowerShellAsync; - private readonly bool _ownsHttpClient; + private readonly ReleaseQueueCheckpointSerializer _checkpointSerializer = new(); + private readonly ReleaseQueueTargetProjectionService _targetProjectionService = new(); + private readonly PublishVerificationHostService _verificationHostService; + private readonly bool _ownsVerificationHostService; public ReleaseVerificationExecutionService() - : this(new HttpClient(new HttpClientHandler { - AllowAutoRedirect = true - }) { - Timeout = TimeSpan.FromSeconds(20) - }, null, ownsHttpClient: true) + : this(new PublishVerificationHostService(), ownsVerificationHostService: true) { } internal ReleaseVerificationExecutionService( HttpClient httpClient, - Func>? runPowerShellAsync) - : this(httpClient, runPowerShellAsync, ownsHttpClient: false) + PowerShellRepositoryResolver powerShellRepositoryResolver) + : this( + new PublishVerificationHostService(httpClient, powerShellRepositoryResolver), + ownsVerificationHostService: false) { } internal ReleaseVerificationExecutionService( - HttpClient httpClient, - Func>? runPowerShellAsync, - bool ownsHttpClient) + PublishVerificationHostService verificationHostService) + : this(verificationHostService, ownsVerificationHostService: false) { - _httpClient = httpClient; - _runPowerShellAsync = runPowerShellAsync ?? ((workingDirectory, script, cancellationToken) => _powerShellCommandRunner.RunCommandAsync(workingDirectory, script, cancellationToken)); - _ownsHttpClient = ownsHttpClient; - if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0) - { - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("PowerForgeStudio/0.1"); - } + } + + internal ReleaseVerificationExecutionService( + PublishVerificationHostService verificationHostService, + bool ownsVerificationHostService) + { + _verificationHostService = verificationHostService ?? throw new ArgumentNullException(nameof(verificationHostService)); + _ownsVerificationHostService = ownsVerificationHostService; } public void Dispose() { - if (_ownsHttpClient) + if (_ownsVerificationHostService) { - _httpClient.Dispose(); + _verificationHostService.Dispose(); } } public IReadOnlyList BuildPendingTargets(IEnumerable queueItems) { - ArgumentNullException.ThrowIfNull(queueItems); - - var targets = new List(); - foreach (var item in queueItems.Where(candidate => candidate.Stage == ReleaseQueueStage.Verify && candidate.Status == ReleaseQueueItemStatus.ReadyToRun)) - { - var publishResult = TryDeserializePublishResult(item); - if (publishResult is null) - { - continue; - } - - foreach (var receipt in publishResult.Receipts) - { - targets.Add(new ReleaseVerificationTarget( - RootPath: receipt.RootPath, - RepositoryName: receipt.RepositoryName, - AdapterKind: receipt.AdapterKind, - TargetName: receipt.TargetName, - TargetKind: receipt.TargetKind, - Destination: receipt.Destination, - SourcePath: receipt.SourcePath)); - } - } - - return targets - .DistinctBy(target => $"{target.RootPath}|{target.AdapterKind}|{target.TargetName}|{target.TargetKind}", StringComparer.OrdinalIgnoreCase) - .ToList(); + return _targetProjectionService.BuildTargets( + queueItems, + ReleaseQueueStage.Verify, + TryDeserializePublishResult, + static (_, publishResult) => publishResult.Receipts.Select(receipt => new ReleaseVerificationTarget( + RootPath: receipt.RootPath, + RepositoryName: receipt.RepositoryName, + AdapterKind: receipt.AdapterKind, + TargetName: receipt.TargetName, + TargetKind: receipt.TargetKind, + Destination: receipt.Destination, + SourcePath: receipt.SourcePath)), + static target => $"{target.RootPath}|{target.AdapterKind}|{target.TargetName}|{target.TargetKind}"); } public async Task ExecuteAsync(ReleaseQueueItem queueItem, CancellationToken cancellationToken = default) @@ -127,19 +101,7 @@ public async Task ExecuteAsync(ReleaseQueueI receipts.Add(await VerifyReceiptAsync(receipt, cancellationToken)); } - var verified = receipts.Count(receipt => receipt.Status == ReleaseVerificationReceiptStatus.Verified); - var skipped = receipts.Count(receipt => receipt.Status == ReleaseVerificationReceiptStatus.Skipped); - var failed = receipts.Count(receipt => receipt.Status == ReleaseVerificationReceiptStatus.Failed); - var summary = failed > 0 - ? $"Verification completed with {verified} verified, {skipped} skipped, and {failed} failed check(s)." - : $"Verification completed with {verified} verified and {skipped} skipped check(s)."; - - return new ReleaseVerificationExecutionResult( - RootPath: queueItem.RootPath, - Succeeded: failed == 0, - Summary: summary, - SourceCheckpointStateJson: queueItem.CheckpointStateJson, - Receipts: receipts); + return ReleaseQueueExecutionResultFactory.CreateVerificationResult(queueItem, receipts); } private async Task VerifyReceiptAsync(ReleasePublishReceipt publishReceipt, CancellationToken cancellationToken) @@ -176,407 +138,63 @@ private async Task VerifyReceiptAsync(ReleasePublish private async Task VerifyGitHubAsync(ReleasePublishReceipt publishReceipt, CancellationToken cancellationToken) { - if (!Uri.TryCreate(publishReceipt.Destination, UriKind.Absolute, out var uri)) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, "GitHub destination URL was not recorded.", publishReceipt.TargetKind); - } - - var response = await SendProbeAsync(uri, cancellationToken); - if (!response.Succeeded) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, "GitHub release probe did not return a success status.", publishReceipt.TargetKind); - } - - var statusCode = response.StatusCode.GetValueOrDefault(); - return VerifiedReceipt(publishReceipt, $"GitHub release probe succeeded with {(int)statusCode} {statusCode}."); + var result = await VerifyWithHostAsync(publishReceipt, cancellationToken); + return MapReceipt(publishReceipt, result); } private async Task VerifyNuGetAsync(ReleasePublishReceipt publishReceipt, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(publishReceipt.SourcePath) || !File.Exists(publishReceipt.SourcePath)) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, "NuGet package path is missing or no longer exists locally.", publishReceipt.TargetKind); - } - - if (string.IsNullOrWhiteSpace(publishReceipt.Destination)) - { - return SkippedReceipt(publishReceipt, "NuGet destination URL was not recorded, so remote verification was skipped."); - } - - var identity = TryReadPackageIdentity(publishReceipt.SourcePath); - if (identity is null) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, "NuGet package identity could not be read from the .nupkg.", publishReceipt.TargetKind); - } - - var probeUri = await ResolveNuGetPackageProbeUriAsync(publishReceipt.Destination, identity, cancellationToken); - if (probeUri is null) - { - return SkippedReceipt(publishReceipt, $"PowerForgeStudio could not derive a probeable package endpoint from {publishReceipt.Destination}."); - } - - var response = await SendProbeAsync(probeUri, cancellationToken); - if (!response.Succeeded) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, $"Package probe failed for {identity.Id} {identity.Version} against {probeUri.Host}.", publishReceipt.TargetKind); - } - - return VerifiedReceipt(publishReceipt, $"Package probe succeeded for {identity.Id} {identity.Version} against {probeUri.Host}."); + var result = await VerifyWithHostAsync(publishReceipt, cancellationToken); + return MapReceipt(publishReceipt, result); } private async Task VerifyPowerShellRepositoryAsync(ReleasePublishReceipt publishReceipt, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(publishReceipt.SourcePath) || !Directory.Exists(publishReceipt.SourcePath)) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, "Module package path is missing or no longer exists locally.", publishReceipt.TargetKind); - } - - var manifestPath = Directory.EnumerateFiles(publishReceipt.SourcePath, "*.psd1", SearchOption.AllDirectories) - .FirstOrDefault(path => !path.Contains($"{Path.DirectorySeparatorChar}en-US{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)); - if (string.IsNullOrWhiteSpace(manifestPath)) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, "Module manifest was not found for PSGallery verification.", publishReceipt.TargetKind); - } - - var moduleInfo = await ReadModuleManifestAsync(publishReceipt.RootPath, manifestPath, cancellationToken); - if (moduleInfo is null) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, "Module manifest could not be read for PSGallery verification.", publishReceipt.TargetKind); - } - - var destination = publishReceipt.Destination ?? "PSGallery"; - if (destination.Equals("PSGallery", StringComparison.OrdinalIgnoreCase)) - { - var galleryVersion = moduleInfo.VersionWithPreRelease; - var url = new Uri($"https://www.powershellgallery.com/packages/{moduleInfo.ModuleName}/{galleryVersion}"); - var galleryResponse = await SendProbeAsync(url, cancellationToken); - if (!galleryResponse.Succeeded) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, $"PSGallery probe failed for {moduleInfo.ModuleName} {galleryVersion}.", publishReceipt.TargetKind); - } - - return VerifiedReceipt(publishReceipt, $"PSGallery probe succeeded for {moduleInfo.ModuleName} {galleryVersion}."); - } - - var resolvedRepository = await ResolvePowerShellRepositoryAsync(publishReceipt.RootPath, destination, cancellationToken); - if (resolvedRepository is null) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, $"PowerShell repository '{destination}' could not be resolved to a probeable endpoint.", publishReceipt.TargetKind); - } - - var probeUri = await ResolveNuGetPackageProbeUriAsync( - resolvedRepository.SourceUri ?? resolvedRepository.PublishUri ?? destination, - new NuGetPackageIdentity(moduleInfo.ModuleName, moduleInfo.VersionWithPreRelease), - cancellationToken); - if (probeUri is null) - { - return SkippedReceipt(publishReceipt, $"PowerForgeStudio could not derive a probeable package endpoint from {resolvedRepository.DisplaySource}."); - } - - var probeResponse = await SendProbeAsync(probeUri, cancellationToken); - if (!probeResponse.Succeeded) - { - return FailedReceipt(publishReceipt.RootPath, publishReceipt.RepositoryName, publishReceipt.AdapterKind, publishReceipt.TargetName, publishReceipt.Destination, $"Repository probe failed for {moduleInfo.ModuleName} {moduleInfo.VersionWithPreRelease} against {probeUri.Host}.", publishReceipt.TargetKind); - } - - return VerifiedReceipt(publishReceipt, $"Repository probe succeeded for {moduleInfo.ModuleName} {moduleInfo.VersionWithPreRelease} against {probeUri.Host}."); - } - - private async Task ResolveNuGetPackageProbeUriAsync(string destination, NuGetPackageIdentity identity, CancellationToken cancellationToken) - { - if (!Uri.TryCreate(destination, UriKind.Absolute, out var destinationUri)) - { - return null; - } - - if (destinationUri.Host.Contains("nuget.org", StringComparison.OrdinalIgnoreCase)) - { - return BuildFlatContainerPackageUri(new Uri("https://api.nuget.org/v3-flatcontainer/"), identity); - } - - if (destinationUri.AbsolutePath.Contains("/v3-flatcontainer", StringComparison.OrdinalIgnoreCase) || - destinationUri.AbsolutePath.Contains("/flatcontainer", StringComparison.OrdinalIgnoreCase)) - { - return BuildFlatContainerPackageUri(destinationUri, identity); - } - - if (destinationUri.AbsolutePath.EndsWith("/index.json", StringComparison.OrdinalIgnoreCase) || - destinationUri.AbsolutePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) - { - var packageBaseUri = await ResolvePackageBaseAddressAsync(destinationUri, cancellationToken); - return packageBaseUri is null ? null : BuildFlatContainerPackageUri(packageBaseUri, identity); - } - - return null; + var result = await VerifyWithHostAsync(publishReceipt, cancellationToken); + return MapReceipt(publishReceipt, result); } - private async Task ResolvePackageBaseAddressAsync(Uri serviceIndexUri, CancellationToken cancellationToken) - { - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, serviceIndexUri); - using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - if ((int)response.StatusCode >= 400) - { - return null; - } - - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); - if (!document.RootElement.TryGetProperty("resources", out var resources) || resources.ValueKind != JsonValueKind.Array) - { - return null; - } - - foreach (var resource in resources.EnumerateArray()) - { - if (!resource.TryGetProperty("@type", out var typeElement) || - typeElement.ValueKind != JsonValueKind.String) - { - continue; - } - - var type = typeElement.GetString(); - if (string.IsNullOrWhiteSpace(type) || - !type.StartsWith("PackageBaseAddress/", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!resource.TryGetProperty("@id", out var idElement) || - idElement.ValueKind != JsonValueKind.String) - { - continue; - } - - var id = idElement.GetString(); - if (string.IsNullOrWhiteSpace(id)) - { - continue; - } - - if (Uri.TryCreate(serviceIndexUri, id, out var resolved)) - { - return resolved; - } - } - } - catch - { - return null; - } - - return null; - } - - private async Task ResolvePowerShellRepositoryAsync(string repositoryRoot, string repositoryName, CancellationToken cancellationToken) - { - if (Uri.TryCreate(repositoryName, UriKind.Absolute, out var directUri)) - { - return new ResolvedPowerShellRepository( - repositoryName, - directUri.AbsoluteUri, - null); - } - - var script = string.Join(Environment.NewLine, new[] { - "$ErrorActionPreference = 'Stop'", - $"$name = {PowerShellScriptEscaping.QuoteLiteral(repositoryName)}", - "$psResourceRepo = Get-Command -Name Get-PSResourceRepository -ErrorAction SilentlyContinue", - "if ($null -ne $psResourceRepo) {", - " $repo = Get-PSResourceRepository -Name $name -ErrorAction SilentlyContinue | Select-Object -First 1", - " if ($null -ne $repo) {", - " @{ Name = $repo.Name; SourceUri = $repo.Uri; PublishUri = $repo.PublishUri } | ConvertTo-Json -Compress", - " exit 0", - " }", - "}", - "$psRepo = Get-PSRepository -Name $name -ErrorAction SilentlyContinue | Select-Object -First 1", - "if ($null -ne $psRepo) {", - " @{ Name = $psRepo.Name; SourceUri = $psRepo.SourceLocation; PublishUri = $psRepo.PublishLocation } | ConvertTo-Json -Compress", - " exit 0", - "}", - "exit 1" - }); - - var execution = await _runPowerShellAsync(repositoryRoot, script, cancellationToken); - if (execution.ExitCode != 0 || string.IsNullOrWhiteSpace(execution.StandardOutput)) - { - return null; - } - - try - { - return JsonSerializer.Deserialize(execution.StandardOutput.Trim(), JsonOptions); - } - catch - { - return null; - } - } - - private static Uri BuildFlatContainerPackageUri(Uri baseUri, NuGetPackageIdentity identity) - { - var builder = new StringBuilder(baseUri.AbsoluteUri.TrimEnd('/')); - builder.Append('/'); - builder.Append(Uri.EscapeDataString(identity.Id.ToLowerInvariant())); - builder.Append('/'); - builder.Append(Uri.EscapeDataString(identity.Version.ToLowerInvariant())); - builder.Append('/'); - builder.Append(Uri.EscapeDataString(identity.Id.ToLowerInvariant())); - builder.Append('.'); - builder.Append(Uri.EscapeDataString(identity.Version.ToLowerInvariant())); - builder.Append(".nupkg"); - return new Uri(builder.ToString(), UriKind.Absolute); - } - - private async Task SendProbeAsync(Uri uri, CancellationToken cancellationToken) - { - using var headRequest = new HttpRequestMessage(HttpMethod.Head, uri); - try - { - using var headResponse = await _httpClient.SendAsync(headRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - if ((int)headResponse.StatusCode < 400) - { - return new ProbeResponse(true, headResponse.StatusCode); - } - } - catch - { - // Fall back to GET. - } - - using var getRequest = new HttpRequestMessage(HttpMethod.Get, uri); - try - { - using var getResponse = await _httpClient.SendAsync(getRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - return (int)getResponse.StatusCode < 400 - ? new ProbeResponse(true, getResponse.StatusCode) - : ProbeResponse.Failed; - } - catch - { - return ProbeResponse.Failed; - } - } - - private static NuGetPackageIdentity? TryReadPackageIdentity(string packagePath) - { - try - { - using var archive = ZipFile.OpenRead(packagePath); - var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); - if (nuspecEntry is null) - { - return null; - } - - using var stream = nuspecEntry.Open(); - using var reader = new StreamReader(stream); - var xml = System.Xml.Linq.XDocument.Load(reader); - var metadata = xml.Root?.Elements().FirstOrDefault(element => element.Name.LocalName.Equals("metadata", StringComparison.OrdinalIgnoreCase)); - var id = metadata?.Elements().FirstOrDefault(element => element.Name.LocalName.Equals("id", StringComparison.OrdinalIgnoreCase))?.Value; - var version = metadata?.Elements().FirstOrDefault(element => element.Name.LocalName.Equals("version", StringComparison.OrdinalIgnoreCase))?.Value; - return string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(version) - ? null - : new NuGetPackageIdentity(id.Trim(), version.Trim()); - } - catch - { - return null; - } - } - - private async Task ReadModuleManifestAsync(string repositoryRoot, string manifestPath, CancellationToken cancellationToken) - { - var script = string.Join("; ", new[] { - "$ErrorActionPreference = 'Stop'", - $"$manifest = Import-PowerShellDataFile -Path {PowerShellScriptEscaping.QuoteLiteral(manifestPath)}", - "$preRelease = $null", - "if ($null -ne $manifest.PrivateData -and $null -ne $manifest.PrivateData.PSData) { $preRelease = $manifest.PrivateData.PSData.Prerelease }", - "@{ ModuleName = [System.IO.Path]::GetFileNameWithoutExtension($manifest.RootModule); ModuleVersion = $manifest.ModuleVersion.ToString(); PreRelease = $preRelease } | ConvertTo-Json -Compress" - }); - - var execution = await _runPowerShellAsync(repositoryRoot, script, cancellationToken); - if (execution.ExitCode != 0) - { - return null; - } - - var module = JsonSerializer.Deserialize(execution.StandardOutput.Trim(), JsonOptions); - return module; - } - - private static ReleasePublishExecutionResult? TryDeserializePublishResult(ReleaseQueueItem queueItem) - { - if (string.IsNullOrWhiteSpace(queueItem.CheckpointStateJson)) - { - return null; - } - - try - { - return JsonSerializer.Deserialize(queueItem.CheckpointStateJson, JsonOptions); - } - catch - { - return null; - } - } + private ReleasePublishExecutionResult? TryDeserializePublishResult(ReleaseQueueItem queueItem) + => _checkpointSerializer.TryDeserialize(queueItem.CheckpointStateJson); private static ReleaseVerificationReceipt VerifiedReceipt(ReleasePublishReceipt publishReceipt, string summary) - => new( - RootPath: publishReceipt.RootPath, - RepositoryName: publishReceipt.RepositoryName, - AdapterKind: publishReceipt.AdapterKind, - TargetName: publishReceipt.TargetName, - TargetKind: publishReceipt.TargetKind, - Destination: publishReceipt.Destination, - Status: ReleaseVerificationReceiptStatus.Verified, - Summary: summary, - VerifiedAtUtc: DateTimeOffset.UtcNow); + => ReleaseQueueReceiptFactory.CreateVerificationReceipt( + publishReceipt, + ReleaseVerificationReceiptStatus.Verified, + summary); private static ReleaseVerificationReceipt SkippedReceipt(ReleasePublishReceipt publishReceipt, string summary) - => new( - RootPath: publishReceipt.RootPath, - RepositoryName: publishReceipt.RepositoryName, - AdapterKind: publishReceipt.AdapterKind, - TargetName: publishReceipt.TargetName, - TargetKind: publishReceipt.TargetKind, - Destination: publishReceipt.Destination, - Status: ReleaseVerificationReceiptStatus.Skipped, - Summary: summary, - VerifiedAtUtc: DateTimeOffset.UtcNow); + => ReleaseQueueReceiptFactory.CreateVerificationReceipt( + publishReceipt, + ReleaseVerificationReceiptStatus.Skipped, + summary); private static ReleaseVerificationReceipt FailedReceipt(string rootPath, string repositoryName, string adapterKind, string targetName, string? destination, string summary, string? targetKind = null) - => new( - RootPath: rootPath, - RepositoryName: repositoryName, - AdapterKind: adapterKind, - TargetName: targetName, - TargetKind: string.IsNullOrWhiteSpace(targetKind) ? targetName : targetKind!, - Destination: destination, - Status: ReleaseVerificationReceiptStatus.Failed, - Summary: summary, - VerifiedAtUtc: DateTimeOffset.UtcNow); - - private sealed record NuGetPackageIdentity(string Id, string Version); - - private readonly record struct ProbeResponse(bool Succeeded, HttpStatusCode? StatusCode) - { - public static ProbeResponse Failed => new(false, null); - } - - private sealed class ModuleManifestInfo - { - public string ModuleName { get; set; } = string.Empty; - [JsonPropertyName("ModuleVersion")] - public string Version { get; set; } = string.Empty; - public string? PreRelease { get; set; } - public string VersionWithPreRelease => string.IsNullOrWhiteSpace(PreRelease) ? Version : $"{Version}-{PreRelease}"; - } - - private sealed record ResolvedPowerShellRepository(string Name, string? SourceUri, string? PublishUri) - { - public string DisplaySource => SourceUri ?? PublishUri ?? Name; - } + => ReleaseQueueReceiptFactory.FailedVerificationReceipt(rootPath, repositoryName, adapterKind, targetName, destination, summary, targetKind); + + private async Task VerifyWithHostAsync(ReleasePublishReceipt publishReceipt, CancellationToken cancellationToken) + => await _verificationHostService.VerifyAsync(new PublishVerificationRequest { + RootPath = publishReceipt.RootPath, + RepositoryName = publishReceipt.RepositoryName, + AdapterKind = publishReceipt.AdapterKind, + TargetName = publishReceipt.TargetName, + TargetKind = publishReceipt.TargetKind, + Destination = publishReceipt.Destination, + SourcePath = publishReceipt.SourcePath + }, cancellationToken).ConfigureAwait(false); + + private static ReleaseVerificationReceipt MapReceipt(ReleasePublishReceipt publishReceipt, PublishVerificationResult result) + => result.Status switch + { + PublishVerificationStatus.Verified => VerifiedReceipt(publishReceipt, result.Summary), + PublishVerificationStatus.Skipped => SkippedReceipt(publishReceipt, result.Summary), + _ => FailedReceipt( + publishReceipt.RootPath, + publishReceipt.RepositoryName, + publishReceipt.AdapterKind, + publishReceipt.TargetName, + publishReceipt.Destination, + result.Summary, + publishReceipt.TargetKind) + }; } diff --git a/PowerForgeStudio.Orchestrator/Storage/ReleaseStateDatabase.cs b/PowerForgeStudio.Orchestrator/Storage/ReleaseStateDatabase.cs index 3b1ccd91..dc22334a 100644 --- a/PowerForgeStudio.Orchestrator/Storage/ReleaseStateDatabase.cs +++ b/PowerForgeStudio.Orchestrator/Storage/ReleaseStateDatabase.cs @@ -1,6 +1,8 @@ using DBAClientX; +using System.Data.Common; using System.Security.Cryptography; using System.Text; +using PowerForgeStudio.Orchestrator.Host; using PowerForgeStudio.Orchestrator.Portfolio; using PowerForgeStudio.Domain.Catalog; using PowerForgeStudio.Domain.Portfolio; @@ -8,6 +10,7 @@ using PowerForgeStudio.Domain.Queue; using PowerForgeStudio.Domain.Signing; using PowerForgeStudio.Domain.Verification; +using PowerForgeStudio.Orchestrator.Queue; namespace PowerForgeStudio.Orchestrator.Storage; @@ -17,6 +20,198 @@ public sealed class ReleaseStateDatabase private readonly SQLite _sqlite = new() { BusyTimeoutMs = 10_000 }; + private static readonly ReceiptTableDefinition SigningReceiptTable = new( + TableName: "release_signing_receipt", + InsertSql: + """ + INSERT INTO release_signing_receipt( + session_id, + root_path, + repository_name, + adapter_kind, + artifact_path, + artifact_kind, + status, + summary, + signed_at_utc) + VALUES ( + @SessionId, + @RootPath, + @RepositoryName, + @AdapterKind, + @ArtifactPath, + @ArtifactKind, + @Status, + @Summary, + @SignedAtUtc); + """, + QuerySql: + """ + SELECT root_path, + repository_name, + adapter_kind, + artifact_path, + artifact_kind, + status, + summary, + signed_at_utc + FROM release_signing_receipt + WHERE session_id = @SessionId + ORDER BY signed_at_utc DESC, repository_name, artifact_path; + """, + BuildParameters: static (sessionId, receipt) => new Dictionary { + ["@SessionId"] = sessionId, + ["@RootPath"] = receipt.RootPath, + ["@RepositoryName"] = receipt.RepositoryName, + ["@AdapterKind"] = receipt.AdapterKind, + ["@ArtifactPath"] = receipt.ArtifactPath, + ["@ArtifactKind"] = receipt.ArtifactKind, + ["@Status"] = receipt.Status.ToString(), + ["@Summary"] = receipt.Summary, + ["@SignedAtUtc"] = receipt.SignedAtUtc.ToString("O") + }, + Map: static reader => new ReleaseSigningReceipt( + RootPath: reader.GetString(0), + RepositoryName: reader.GetString(1), + AdapterKind: reader.GetString(2), + ArtifactPath: reader.GetString(3), + ArtifactKind: reader.GetString(4), + Status: Enum.Parse(reader.GetString(5), ignoreCase: true), + Summary: reader.GetString(6), + SignedAtUtc: DateTimeOffset.Parse(reader.GetString(7)))); + private static readonly ReceiptTableDefinition PublishReceiptTable = new( + TableName: "release_publish_receipt", + InsertSql: + """ + INSERT INTO release_publish_receipt( + session_id, + root_path, + repository_name, + adapter_kind, + target_name, + target_kind, + destination, + source_path, + status, + summary, + published_at_utc) + VALUES ( + @SessionId, + @RootPath, + @RepositoryName, + @AdapterKind, + @TargetName, + @TargetKind, + @Destination, + @SourcePath, + @Status, + @Summary, + @PublishedAtUtc); + """, + QuerySql: + """ + SELECT root_path, + repository_name, + adapter_kind, + target_name, + target_kind, + destination, + source_path, + status, + summary, + published_at_utc + FROM release_publish_receipt + WHERE session_id = @SessionId + ORDER BY published_at_utc DESC, repository_name, target_name; + """, + BuildParameters: static (sessionId, receipt) => new Dictionary { + ["@SessionId"] = sessionId, + ["@RootPath"] = receipt.RootPath, + ["@RepositoryName"] = receipt.RepositoryName, + ["@AdapterKind"] = receipt.AdapterKind, + ["@TargetName"] = receipt.TargetName, + ["@TargetKind"] = receipt.TargetKind, + ["@Destination"] = receipt.Destination, + ["@SourcePath"] = receipt.SourcePath, + ["@Status"] = receipt.Status.ToString(), + ["@Summary"] = receipt.Summary, + ["@PublishedAtUtc"] = receipt.PublishedAtUtc.ToString("O") + }, + Map: static reader => new ReleasePublishReceipt( + RootPath: reader.GetString(0), + RepositoryName: reader.GetString(1), + AdapterKind: reader.GetString(2), + TargetName: reader.GetString(3), + TargetKind: reader.GetString(4), + Destination: reader.IsDBNull(5) ? null : reader.GetString(5), + SourcePath: reader.IsDBNull(6) ? null : reader.GetString(6), + Status: Enum.Parse(reader.GetString(7), ignoreCase: true), + Summary: reader.GetString(8), + PublishedAtUtc: DateTimeOffset.Parse(reader.GetString(9)))); + private static readonly ReceiptTableDefinition VerificationReceiptTable = new( + TableName: "release_verification_receipt", + InsertSql: + """ + INSERT INTO release_verification_receipt( + session_id, + root_path, + repository_name, + adapter_kind, + target_name, + target_kind, + destination, + status, + summary, + verified_at_utc) + VALUES ( + @SessionId, + @RootPath, + @RepositoryName, + @AdapterKind, + @TargetName, + @TargetKind, + @Destination, + @Status, + @Summary, + @VerifiedAtUtc); + """, + QuerySql: + """ + SELECT root_path, + repository_name, + adapter_kind, + target_name, + target_kind, + destination, + status, + summary, + verified_at_utc + FROM release_verification_receipt + WHERE session_id = @SessionId + ORDER BY verified_at_utc DESC, repository_name, target_name; + """, + BuildParameters: static (sessionId, receipt) => new Dictionary { + ["@SessionId"] = sessionId, + ["@RootPath"] = receipt.RootPath, + ["@RepositoryName"] = receipt.RepositoryName, + ["@AdapterKind"] = receipt.AdapterKind, + ["@TargetName"] = receipt.TargetName, + ["@TargetKind"] = receipt.TargetKind, + ["@Destination"] = receipt.Destination, + ["@Status"] = receipt.Status.ToString(), + ["@Summary"] = receipt.Summary, + ["@VerifiedAtUtc"] = receipt.VerifiedAtUtc.ToString("O") + }, + Map: static reader => new ReleaseVerificationReceipt( + RootPath: reader.GetString(0), + RepositoryName: reader.GetString(1), + AdapterKind: reader.GetString(2), + TargetName: reader.GetString(3), + TargetKind: reader.GetString(4), + Destination: reader.IsDBNull(5) ? null : reader.GetString(5), + Status: Enum.Parse(reader.GetString(6), ignoreCase: true), + Summary: reader.GetString(7), + VerifiedAtUtc: DateTimeOffset.Parse(reader.GetString(8)))); private static readonly IReadOnlyDictionary> AllowedSchemaColumns = new Dictionary>(StringComparer.OrdinalIgnoreCase) { ["release_portfolio_view_state"] = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -48,8 +243,7 @@ public ReleaseStateDatabase(string databasePath) public static string GetDefaultDatabasePath() { - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return Path.Combine(localAppData, "PowerForgeStudio", "releaseops.db"); + return PowerForgeStudioHostPaths.GetDefaultDatabasePath(); } public static async ValueTask AcquireExclusiveAccessAsync( @@ -511,145 +705,136 @@ public async Task PersistPortfolioSnapshotAsync(IEnumerable new Dictionary { + ["@RootPath"] = item.RootPath, + ["@Name"] = item.Name, + ["@RepositoryKind"] = item.RepositoryKind.ToString(), + ["@WorkspaceKind"] = item.WorkspaceKind.ToString(), + ["@ModuleBuildScriptPath"] = item.Repository.ModuleBuildScriptPath, + ["@ProjectBuildScriptPath"] = item.Repository.ProjectBuildScriptPath, + ["@IsWorktree"] = item.Repository.IsWorktree ? 1 : 0, + ["@HasWebsiteSignals"] = item.Repository.HasWebsiteSignals ? 1 : 0, + ["@IsGitRepository"] = item.Git.IsGitRepository ? 1 : 0, + ["@BranchName"] = item.Git.BranchName, + ["@UpstreamBranch"] = item.Git.UpstreamBranch, + ["@AheadCount"] = item.Git.AheadCount, + ["@BehindCount"] = item.Git.BehindCount, + ["@TrackedChangeCount"] = item.Git.TrackedChangeCount, + ["@UntrackedChangeCount"] = item.Git.UntrackedChangeCount, + ["@ReadinessKind"] = item.Readiness.Kind.ToString(), + ["@ReadinessReason"] = item.Readiness.Reason, + ["@ScannedAtUtc"] = scannedAtUtc + }, useTransaction: true, cancellationToken: cancellationToken).ConfigureAwait(false); - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - "DELETE FROM release_portfolio_signal_snapshot;", + await ReplaceRowsAsync( + deleteSql: "DELETE FROM release_portfolio_signal_snapshot;", + deleteParameters: null, + insertSql: + """ + INSERT INTO release_portfolio_signal_snapshot( + root_path, + github_repository_slug, + github_status, + github_open_pr_count, + github_latest_workflow_failed, + github_latest_release_tag, + github_default_branch, + github_probed_branch, + github_is_default_branch, + github_branch_protection_enabled, + github_summary, + github_detail, + drift_status, + drift_summary, + drift_detail, + scanned_at_utc) + VALUES ( + @RootPath, + @GitHubRepositorySlug, + @GitHubStatus, + @GitHubOpenPullRequestCount, + @GitHubLatestWorkflowFailed, + @GitHubLatestReleaseTag, + @GitHubDefaultBranch, + @GitHubProbedBranch, + @GitHubIsDefaultBranch, + @GitHubBranchProtectionEnabled, + @GitHubSummary, + @GitHubDetail, + @DriftStatus, + @DriftSummary, + @DriftDetail, + @ScannedAtUtc); + """, + rows: materializedItems, + buildParameters: item => new Dictionary { + ["@RootPath"] = item.RootPath, + ["@GitHubRepositorySlug"] = item.GitHubInbox?.RepositorySlug, + ["@GitHubStatus"] = item.GitHubInbox?.Status.ToString(), + ["@GitHubOpenPullRequestCount"] = item.GitHubInbox?.OpenPullRequestCount, + ["@GitHubLatestWorkflowFailed"] = item.GitHubInbox?.LatestWorkflowFailed is null ? null : item.GitHubInbox.LatestWorkflowFailed.Value ? 1 : 0, + ["@GitHubLatestReleaseTag"] = item.GitHubInbox?.LatestReleaseTag, + ["@GitHubDefaultBranch"] = item.GitHubInbox?.DefaultBranch, + ["@GitHubProbedBranch"] = item.GitHubInbox?.ProbedBranch, + ["@GitHubIsDefaultBranch"] = item.GitHubInbox?.IsDefaultBranch is null ? null : item.GitHubInbox.IsDefaultBranch.Value ? 1 : 0, + ["@GitHubBranchProtectionEnabled"] = item.GitHubInbox?.BranchProtectionEnabled is null ? null : item.GitHubInbox.BranchProtectionEnabled.Value ? 1 : 0, + ["@GitHubSummary"] = item.GitHubInbox?.Summary, + ["@GitHubDetail"] = item.GitHubInbox?.Detail, + ["@DriftStatus"] = item.ReleaseDrift?.Status.ToString(), + ["@DriftSummary"] = item.ReleaseDrift?.Summary, + ["@DriftDetail"] = item.ReleaseDrift?.Detail, + ["@ScannedAtUtc"] = scannedAtUtc + }, useTransaction: true, cancellationToken: cancellationToken).ConfigureAwait(false); - foreach (var item in materializedItems) - { - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - """ - INSERT INTO release_portfolio_snapshot( - root_path, - name, - repository_kind, - workspace_kind, - module_build_script_path, - project_build_script_path, - is_worktree, - has_website_signals, - is_git_repository, - branch_name, - upstream_branch, - ahead_count, - behind_count, - tracked_change_count, - untracked_change_count, - readiness_kind, - readiness_reason, - scanned_at_utc) - VALUES ( - @RootPath, - @Name, - @RepositoryKind, - @WorkspaceKind, - @ModuleBuildScriptPath, - @ProjectBuildScriptPath, - @IsWorktree, - @HasWebsiteSignals, - @IsGitRepository, - @BranchName, - @UpstreamBranch, - @AheadCount, - @BehindCount, - @TrackedChangeCount, - @UntrackedChangeCount, - @ReadinessKind, - @ReadinessReason, - @ScannedAtUtc); - """, - new Dictionary { - ["@RootPath"] = item.RootPath, - ["@Name"] = item.Name, - ["@RepositoryKind"] = item.RepositoryKind.ToString(), - ["@WorkspaceKind"] = item.WorkspaceKind.ToString(), - ["@ModuleBuildScriptPath"] = item.Repository.ModuleBuildScriptPath, - ["@ProjectBuildScriptPath"] = item.Repository.ProjectBuildScriptPath, - ["@IsWorktree"] = item.Repository.IsWorktree ? 1 : 0, - ["@HasWebsiteSignals"] = item.Repository.HasWebsiteSignals ? 1 : 0, - ["@IsGitRepository"] = item.Git.IsGitRepository ? 1 : 0, - ["@BranchName"] = item.Git.BranchName, - ["@UpstreamBranch"] = item.Git.UpstreamBranch, - ["@AheadCount"] = item.Git.AheadCount, - ["@BehindCount"] = item.Git.BehindCount, - ["@TrackedChangeCount"] = item.Git.TrackedChangeCount, - ["@UntrackedChangeCount"] = item.Git.UntrackedChangeCount, - ["@ReadinessKind"] = item.Readiness.Kind.ToString(), - ["@ReadinessReason"] = item.Readiness.Reason, - ["@ScannedAtUtc"] = scannedAtUtc - }, - useTransaction: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - """ - INSERT INTO release_portfolio_signal_snapshot( - root_path, - github_repository_slug, - github_status, - github_open_pr_count, - github_latest_workflow_failed, - github_latest_release_tag, - github_default_branch, - github_probed_branch, - github_is_default_branch, - github_branch_protection_enabled, - github_summary, - github_detail, - drift_status, - drift_summary, - drift_detail, - scanned_at_utc) - VALUES ( - @RootPath, - @GitHubRepositorySlug, - @GitHubStatus, - @GitHubOpenPullRequestCount, - @GitHubLatestWorkflowFailed, - @GitHubLatestReleaseTag, - @GitHubDefaultBranch, - @GitHubProbedBranch, - @GitHubIsDefaultBranch, - @GitHubBranchProtectionEnabled, - @GitHubSummary, - @GitHubDetail, - @DriftStatus, - @DriftSummary, - @DriftDetail, - @ScannedAtUtc); - """, - new Dictionary { - ["@RootPath"] = item.RootPath, - ["@GitHubRepositorySlug"] = item.GitHubInbox?.RepositorySlug, - ["@GitHubStatus"] = item.GitHubInbox?.Status.ToString(), - ["@GitHubOpenPullRequestCount"] = item.GitHubInbox?.OpenPullRequestCount, - ["@GitHubLatestWorkflowFailed"] = item.GitHubInbox?.LatestWorkflowFailed is null ? null : item.GitHubInbox.LatestWorkflowFailed.Value ? 1 : 0, - ["@GitHubLatestReleaseTag"] = item.GitHubInbox?.LatestReleaseTag, - ["@GitHubDefaultBranch"] = item.GitHubInbox?.DefaultBranch, - ["@GitHubProbedBranch"] = item.GitHubInbox?.ProbedBranch, - ["@GitHubIsDefaultBranch"] = item.GitHubInbox?.IsDefaultBranch is null ? null : item.GitHubInbox.IsDefaultBranch.Value ? 1 : 0, - ["@GitHubBranchProtectionEnabled"] = item.GitHubInbox?.BranchProtectionEnabled is null ? null : item.GitHubInbox.BranchProtectionEnabled.Value ? 1 : 0, - ["@GitHubSummary"] = item.GitHubInbox?.Summary, - ["@GitHubDetail"] = item.GitHubInbox?.Detail, - ["@DriftStatus"] = item.ReleaseDrift?.Status.ToString(), - ["@DriftSummary"] = item.ReleaseDrift?.Summary, - ["@DriftDetail"] = item.ReleaseDrift?.Detail, - ["@ScannedAtUtc"] = scannedAtUtc - }, - useTransaction: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - } - await _sqlite.CommitAsync(cancellationToken).ConfigureAwait(false); } catch @@ -849,56 +1034,52 @@ FROM release_portfolio_snapshot public async Task PersistPlanSnapshotsAsync(IEnumerable items, CancellationToken cancellationToken = default) { var scannedAtUtc = DateTime.UtcNow.ToString("O"); - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - "DELETE FROM release_plan_snapshot;", - cancellationToken: cancellationToken).ConfigureAwait(false); + var rows = items + .SelectMany(item => (item.PlanResults ?? []).Select(result => new PlanSnapshotWriteRow(item.RootPath, result))) + .ToArray(); - foreach (var item in items) - { - foreach (var result in item.PlanResults ?? []) - { - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - """ - INSERT INTO release_plan_snapshot( - root_path, - adapter_kind, - status, - summary, - plan_path, - exit_code, - duration_seconds, - output_tail, - error_tail, - scanned_at_utc) - VALUES ( - @RootPath, - @AdapterKind, - @Status, - @Summary, - @PlanPath, - @ExitCode, - @DurationSeconds, - @OutputTail, - @ErrorTail, - @ScannedAtUtc); - """, - new Dictionary { - ["@RootPath"] = item.RootPath, - ["@AdapterKind"] = result.AdapterKind.ToString(), - ["@Status"] = result.Status.ToString(), - ["@Summary"] = result.Summary, - ["@PlanPath"] = result.PlanPath, - ["@ExitCode"] = result.ExitCode, - ["@DurationSeconds"] = result.DurationSeconds, - ["@OutputTail"] = result.OutputTail, - ["@ErrorTail"] = result.ErrorTail, - ["@ScannedAtUtc"] = scannedAtUtc - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - } - } + await ReplaceRowsAsync( + deleteSql: "DELETE FROM release_plan_snapshot;", + deleteParameters: null, + insertSql: + """ + INSERT INTO release_plan_snapshot( + root_path, + adapter_kind, + status, + summary, + plan_path, + exit_code, + duration_seconds, + output_tail, + error_tail, + scanned_at_utc) + VALUES ( + @RootPath, + @AdapterKind, + @Status, + @Summary, + @PlanPath, + @ExitCode, + @DurationSeconds, + @OutputTail, + @ErrorTail, + @ScannedAtUtc); + """, + rows: rows, + buildParameters: row => new Dictionary { + ["@RootPath"] = row.RootPath, + ["@AdapterKind"] = row.Result.AdapterKind.ToString(), + ["@Status"] = row.Result.Status.ToString(), + ["@Summary"] = row.Result.Summary, + ["@PlanPath"] = row.Result.PlanPath, + ["@ExitCode"] = row.Result.ExitCode, + ["@DurationSeconds"] = row.Result.DurationSeconds, + ["@OutputTail"] = row.Result.OutputTail, + ["@ErrorTail"] = row.Result.ErrorTail, + ["@ScannedAtUtc"] = scannedAtUtc + }, + cancellationToken: cancellationToken).ConfigureAwait(false); } public async Task PersistQueueSessionAsync(ReleaseQueueSession session, CancellationToken cancellationToken = default) @@ -957,62 +1138,56 @@ ON CONFLICT(session_id) DO UPDATE SET }, cancellationToken: cancellationToken).ConfigureAwait(false); - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - "DELETE FROM release_queue_item WHERE session_id = @SessionId;", - new Dictionary { + await ReplaceRowsAsync( + deleteSql: "DELETE FROM release_queue_item WHERE session_id = @SessionId;", + deleteParameters: new Dictionary { ["@SessionId"] = session.SessionId }, + insertSql: + """ + INSERT INTO release_queue_item( + session_id, + root_path, + repository_name, + repository_kind, + workspace_kind, + queue_order, + stage, + status, + summary, + checkpoint_key, + checkpoint_state_json, + updated_at_utc) + VALUES ( + @SessionId, + @RootPath, + @RepositoryName, + @RepositoryKind, + @WorkspaceKind, + @QueueOrder, + @Stage, + @Status, + @Summary, + @CheckpointKey, + @CheckpointStateJson, + @UpdatedAtUtc); + """, + rows: session.Items, + buildParameters: item => new Dictionary { + ["@SessionId"] = session.SessionId, + ["@RootPath"] = item.RootPath, + ["@RepositoryName"] = item.RepositoryName, + ["@RepositoryKind"] = item.RepositoryKind.ToString(), + ["@WorkspaceKind"] = item.WorkspaceKind.ToString(), + ["@QueueOrder"] = item.QueueOrder, + ["@Stage"] = item.Stage.ToString(), + ["@Status"] = item.Status.ToString(), + ["@Summary"] = item.Summary, + ["@CheckpointKey"] = item.CheckpointKey, + ["@CheckpointStateJson"] = item.CheckpointStateJson, + ["@UpdatedAtUtc"] = item.UpdatedAtUtc.ToString("O") + }, cancellationToken: cancellationToken).ConfigureAwait(false); - - foreach (var item in session.Items) - { - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - """ - INSERT INTO release_queue_item( - session_id, - root_path, - repository_name, - repository_kind, - workspace_kind, - queue_order, - stage, - status, - summary, - checkpoint_key, - checkpoint_state_json, - updated_at_utc) - VALUES ( - @SessionId, - @RootPath, - @RepositoryName, - @RepositoryKind, - @WorkspaceKind, - @QueueOrder, - @Stage, - @Status, - @Summary, - @CheckpointKey, - @CheckpointStateJson, - @UpdatedAtUtc); - """, - new Dictionary { - ["@SessionId"] = session.SessionId, - ["@RootPath"] = item.RootPath, - ["@RepositoryName"] = item.RepositoryName, - ["@RepositoryKind"] = item.RepositoryKind.ToString(), - ["@WorkspaceKind"] = item.WorkspaceKind.ToString(), - ["@QueueOrder"] = item.QueueOrder, - ["@Stage"] = item.Stage.ToString(), - ["@Status"] = item.Status.ToString(), - ["@Summary"] = item.Summary, - ["@CheckpointKey"] = item.CheckpointKey, - ["@CheckpointStateJson"] = item.CheckpointStateJson, - ["@UpdatedAtUtc"] = item.UpdatedAtUtc.ToString("O") - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - } } public async Task LoadLatestQueueSessionAsync(CancellationToken cancellationToken = default) @@ -1090,284 +1265,43 @@ FROM release_queue_item }, cancellationToken: cancellationToken).ConfigureAwait(false); - return new ReleaseQueueSession( - SessionId: sessionRow.SessionId, - WorkspaceRoot: sessionRow.WorkspaceRoot, - CreatedAtUtc: sessionRow.CreatedAtUtc, - Summary: new ReleaseQueueSummary( - TotalItems: sessionRow.TotalItems, - BuildReadyItems: sessionRow.BuildReadyItems, - PreparePendingItems: sessionRow.PreparePendingItems, - WaitingApprovalItems: sessionRow.WaitingApprovalItems, - BlockedItems: sessionRow.BlockedItems, - VerificationReadyItems: sessionRow.VerificationReadyItems), - Items: items, - ScopeKey: sessionRow.ScopeKey, - ScopeDisplayName: sessionRow.ScopeDisplayName); + return ReleaseQueueSessionFactory.Create( + workspaceRoot: sessionRow.WorkspaceRoot, + items: items, + createdAtUtc: sessionRow.CreatedAtUtc, + scopeKey: sessionRow.ScopeKey, + scopeDisplayName: sessionRow.ScopeDisplayName, + sessionId: sessionRow.SessionId); } public async Task PersistSigningReceiptsAsync(string sessionId, IEnumerable receipts, CancellationToken cancellationToken = default) { - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - "DELETE FROM release_signing_receipt WHERE session_id = @SessionId;", - new Dictionary { - ["@SessionId"] = sessionId - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - - foreach (var receipt in receipts) - { - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - """ - INSERT INTO release_signing_receipt( - session_id, - root_path, - repository_name, - adapter_kind, - artifact_path, - artifact_kind, - status, - summary, - signed_at_utc) - VALUES ( - @SessionId, - @RootPath, - @RepositoryName, - @AdapterKind, - @ArtifactPath, - @ArtifactKind, - @Status, - @Summary, - @SignedAtUtc); - """, - new Dictionary { - ["@SessionId"] = sessionId, - ["@RootPath"] = receipt.RootPath, - ["@RepositoryName"] = receipt.RepositoryName, - ["@AdapterKind"] = receipt.AdapterKind, - ["@ArtifactPath"] = receipt.ArtifactPath, - ["@ArtifactKind"] = receipt.ArtifactKind, - ["@Status"] = receipt.Status.ToString(), - ["@Summary"] = receipt.Summary, - ["@SignedAtUtc"] = receipt.SignedAtUtc.ToString("O") - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - } + await PersistReceiptRowsAsync(sessionId, receipts, SigningReceiptTable, cancellationToken).ConfigureAwait(false); } public async Task> LoadSigningReceiptsAsync(string sessionId, CancellationToken cancellationToken = default) { - return await _sqlite.QueryReadOnlyAsListAsync( - DatabasePath, - """ - SELECT root_path, - repository_name, - adapter_kind, - artifact_path, - artifact_kind, - status, - summary, - signed_at_utc - FROM release_signing_receipt - WHERE session_id = @SessionId - ORDER BY signed_at_utc DESC, repository_name, artifact_path; - """, - reader => new ReleaseSigningReceipt( - RootPath: reader.GetString(0), - RepositoryName: reader.GetString(1), - AdapterKind: reader.GetString(2), - ArtifactPath: reader.GetString(3), - ArtifactKind: reader.GetString(4), - Status: Enum.Parse(reader.GetString(5), ignoreCase: true), - Summary: reader.GetString(6), - SignedAtUtc: DateTimeOffset.Parse(reader.GetString(7))), - new Dictionary { - ["@SessionId"] = sessionId - }, - cancellationToken: cancellationToken).ConfigureAwait(false); + return await LoadReceiptRowsAsync(sessionId, SigningReceiptTable, cancellationToken).ConfigureAwait(false); } public async Task PersistPublishReceiptsAsync(string sessionId, IEnumerable receipts, CancellationToken cancellationToken = default) { - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - "DELETE FROM release_publish_receipt WHERE session_id = @SessionId;", - new Dictionary { - ["@SessionId"] = sessionId - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - - foreach (var receipt in receipts) - { - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - """ - INSERT INTO release_publish_receipt( - session_id, - root_path, - repository_name, - adapter_kind, - target_name, - target_kind, - destination, - source_path, - status, - summary, - published_at_utc) - VALUES ( - @SessionId, - @RootPath, - @RepositoryName, - @AdapterKind, - @TargetName, - @TargetKind, - @Destination, - @SourcePath, - @Status, - @Summary, - @PublishedAtUtc); - """, - new Dictionary { - ["@SessionId"] = sessionId, - ["@RootPath"] = receipt.RootPath, - ["@RepositoryName"] = receipt.RepositoryName, - ["@AdapterKind"] = receipt.AdapterKind, - ["@TargetName"] = receipt.TargetName, - ["@TargetKind"] = receipt.TargetKind, - ["@Destination"] = receipt.Destination, - ["@SourcePath"] = receipt.SourcePath, - ["@Status"] = receipt.Status.ToString(), - ["@Summary"] = receipt.Summary, - ["@PublishedAtUtc"] = receipt.PublishedAtUtc.ToString("O") - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - } + await PersistReceiptRowsAsync(sessionId, receipts, PublishReceiptTable, cancellationToken).ConfigureAwait(false); } public async Task> LoadPublishReceiptsAsync(string sessionId, CancellationToken cancellationToken = default) { - return await _sqlite.QueryReadOnlyAsListAsync( - DatabasePath, - """ - SELECT root_path, - repository_name, - adapter_kind, - target_name, - target_kind, - destination, - source_path, - status, - summary, - published_at_utc - FROM release_publish_receipt - WHERE session_id = @SessionId - ORDER BY published_at_utc DESC, repository_name, target_name; - """, - reader => new ReleasePublishReceipt( - RootPath: reader.GetString(0), - RepositoryName: reader.GetString(1), - AdapterKind: reader.GetString(2), - TargetName: reader.GetString(3), - TargetKind: reader.GetString(4), - Destination: reader.IsDBNull(5) ? null : reader.GetString(5), - SourcePath: reader.IsDBNull(6) ? null : reader.GetString(6), - Status: Enum.Parse(reader.GetString(7), ignoreCase: true), - Summary: reader.GetString(8), - PublishedAtUtc: DateTimeOffset.Parse(reader.GetString(9))), - new Dictionary { - ["@SessionId"] = sessionId - }, - cancellationToken: cancellationToken).ConfigureAwait(false); + return await LoadReceiptRowsAsync(sessionId, PublishReceiptTable, cancellationToken).ConfigureAwait(false); } public async Task PersistVerificationReceiptsAsync(string sessionId, IEnumerable receipts, CancellationToken cancellationToken = default) { - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - "DELETE FROM release_verification_receipt WHERE session_id = @SessionId;", - new Dictionary { - ["@SessionId"] = sessionId - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - - foreach (var receipt in receipts) - { - await _sqlite.ExecuteNonQueryAsync( - DatabasePath, - """ - INSERT INTO release_verification_receipt( - session_id, - root_path, - repository_name, - adapter_kind, - target_name, - target_kind, - destination, - status, - summary, - verified_at_utc) - VALUES ( - @SessionId, - @RootPath, - @RepositoryName, - @AdapterKind, - @TargetName, - @TargetKind, - @Destination, - @Status, - @Summary, - @VerifiedAtUtc); - """, - new Dictionary { - ["@SessionId"] = sessionId, - ["@RootPath"] = receipt.RootPath, - ["@RepositoryName"] = receipt.RepositoryName, - ["@AdapterKind"] = receipt.AdapterKind, - ["@TargetName"] = receipt.TargetName, - ["@TargetKind"] = receipt.TargetKind, - ["@Destination"] = receipt.Destination, - ["@Status"] = receipt.Status.ToString(), - ["@Summary"] = receipt.Summary, - ["@VerifiedAtUtc"] = receipt.VerifiedAtUtc.ToString("O") - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - } + await PersistReceiptRowsAsync(sessionId, receipts, VerificationReceiptTable, cancellationToken).ConfigureAwait(false); } public async Task> LoadVerificationReceiptsAsync(string sessionId, CancellationToken cancellationToken = default) { - return await _sqlite.QueryReadOnlyAsListAsync( - DatabasePath, - """ - SELECT root_path, - repository_name, - adapter_kind, - target_name, - target_kind, - destination, - status, - summary, - verified_at_utc - FROM release_verification_receipt - WHERE session_id = @SessionId - ORDER BY verified_at_utc DESC, repository_name, target_name; - """, - reader => new ReleaseVerificationReceipt( - RootPath: reader.GetString(0), - RepositoryName: reader.GetString(1), - AdapterKind: reader.GetString(2), - TargetName: reader.GetString(3), - TargetKind: reader.GetString(4), - Destination: reader.IsDBNull(5) ? null : reader.GetString(5), - Status: Enum.Parse(reader.GetString(6), ignoreCase: true), - Summary: reader.GetString(7), - VerifiedAtUtc: DateTimeOffset.Parse(reader.GetString(8))), - new Dictionary { - ["@SessionId"] = sessionId - }, - cancellationToken: cancellationToken).ConfigureAwait(false); + return await LoadReceiptRowsAsync(sessionId, VerificationReceiptTable, cancellationToken).ConfigureAwait(false); } public async Task PersistGitQuickActionReceiptAsync(RepositoryGitQuickActionReceipt receipt, CancellationToken cancellationToken = default) @@ -1464,6 +1398,121 @@ private readonly record struct QueueSessionRow( int VerificationReadyItems, DateTimeOffset CreatedAtUtc); + private readonly record struct ReceiptTableDefinition( + string TableName, + string InsertSql, + string QuerySql, + Func> BuildParameters, + Func Map); + + private async Task PersistReceiptRowsAsync( + string sessionId, + IEnumerable receipts, + ReceiptTableDefinition table, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(table.BuildParameters); + + await ReplaceReceiptRowsAsync( + sessionId, + table.TableName, + table.InsertSql, + receipts, + receipt => table.BuildParameters(sessionId, receipt), + cancellationToken).ConfigureAwait(false); + } + + private async Task> LoadReceiptRowsAsync( + string sessionId, + ReceiptTableDefinition table, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(table.Map); + + return await LoadReceiptRowsAsync( + sessionId, + table.QuerySql, + table.Map, + cancellationToken).ConfigureAwait(false); + } + + private async Task ReplaceReceiptRowsAsync( + string sessionId, + string tableName, + string insertSql, + IEnumerable receipts, + Func> buildParameters, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + ArgumentException.ThrowIfNullOrWhiteSpace(insertSql); + ArgumentNullException.ThrowIfNull(receipts); + ArgumentNullException.ThrowIfNull(buildParameters); + + await ReplaceRowsAsync( + deleteSql: $"DELETE FROM {tableName} WHERE session_id = @SessionId;", + deleteParameters: new Dictionary { + ["@SessionId"] = sessionId + }, + insertSql: insertSql, + rows: receipts, + buildParameters: buildParameters, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private async Task ReplaceRowsAsync( + string deleteSql, + IReadOnlyDictionary? deleteParameters, + string insertSql, + IEnumerable rows, + Func> buildParameters, + bool useTransaction = false, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(deleteSql); + ArgumentException.ThrowIfNullOrWhiteSpace(insertSql); + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(buildParameters); + + await _sqlite.ExecuteNonQueryAsync( + DatabasePath, + deleteSql, + deleteParameters is null ? null : new Dictionary(deleteParameters), + useTransaction: useTransaction, + cancellationToken: cancellationToken).ConfigureAwait(false); + + foreach (var row in rows) + { + await _sqlite.ExecuteNonQueryAsync( + DatabasePath, + insertSql, + buildParameters(row), + useTransaction: useTransaction, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + private async Task> LoadReceiptRowsAsync( + string sessionId, + string querySql, + Func map, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(querySql); + ArgumentNullException.ThrowIfNull(map); + + return await _sqlite.QueryReadOnlyAsListAsync( + DatabasePath, + querySql, + map, + new Dictionary { + ["@SessionId"] = sessionId + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + private readonly record struct PortfolioSnapshotRow( string RootPath, string Name, @@ -1494,6 +1543,10 @@ private readonly record struct PlanSnapshotRow( string? OutputTail, string? ErrorTail); + private readonly record struct PlanSnapshotWriteRow( + string RootPath, + RepositoryPlanResult Result); + private readonly record struct SignalSnapshotRow( string RootPath, string? GitHubRepositorySlug, diff --git a/PowerForgeStudio.Tests/PowerForgeStudioGitRemoteResolverTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioGitRemoteResolverTests.cs new file mode 100644 index 00000000..39cb3f4f --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioGitRemoteResolverTests.cs @@ -0,0 +1,42 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Portfolio; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioGitRemoteResolverTests +{ + [Fact] + public async Task ResolveOriginUrlAsync_UsesSharedGitClientContract() + { + string? capturedRepositoryRoot = null; + string? capturedRemoteName = null; + var resolver = new GitRemoteResolver((repositoryRoot, remoteName, _) => { + capturedRepositoryRoot = repositoryRoot; + capturedRemoteName = remoteName; + return Task.FromResult(new GitCommandResult( + GitCommandKind.GetRemoteUrl, + repositoryRoot, + "git remote get-url origin", + 0, + "https://github.com/EvotecIT/PSPublishModule.git", + string.Empty, + "git", + TimeSpan.Zero, + timedOut: false)); + }); + var repositoryRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForgeStudio.Tests", Guid.NewGuid().ToString("N"))).FullName; + + try + { + var url = await resolver.ResolveOriginUrlAsync(repositoryRoot); + + Assert.Equal(repositoryRoot, capturedRepositoryRoot); + Assert.Equal("origin", capturedRemoteName); + Assert.Equal("https://github.com/EvotecIT/PSPublishModule.git", url); + } + finally + { + try { Directory.Delete(repositoryRoot, recursive: true); } catch { } + } + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioHostPathsTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioHostPathsTests.cs new file mode 100644 index 00000000..c4d6b96f --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioHostPathsTests.cs @@ -0,0 +1,41 @@ +using PowerForgeStudio.Orchestrator.Host; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioHostPathsTests +{ + [Fact] + public void GetScopedFilePath_CreatesSanitizedDirectoryStructure() + { + var localAppDataRoot = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests", Guid.NewGuid().ToString("N")); + + try + { + var filePath = PowerForgeStudioHostPaths.GetScopedFilePath( + repositoryName: "Repo:One", + areaName: "runtime", + scopeName: "project/publish", + fileName: "plan.json", + localApplicationDataPath: localAppDataRoot); + + var expectedDirectory = Path.Combine(localAppDataRoot, "PowerForgeStudio", "runtime", "Repo_One", "project_publish"); + Assert.Equal(Path.Combine(expectedDirectory, "plan.json"), filePath); + Assert.True(Directory.Exists(expectedDirectory)); + } + finally + { + if (Directory.Exists(localAppDataRoot)) + { + Directory.Delete(localAppDataRoot, recursive: true); + } + } + } + + [Fact] + public void GetStudioRootPath_UsesProvidedLocalAppDataRoot() + { + var localAppDataRoot = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests", Guid.NewGuid().ToString("N")); + var studioRoot = PowerForgeStudioHostPaths.GetStudioRootPath(localAppDataRoot); + Assert.Equal(Path.Combine(localAppDataRoot, "PowerForgeStudio"), studioRoot); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioPowerShellCommandRunnerTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioPowerShellCommandRunnerTests.cs new file mode 100644 index 00000000..577dae23 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioPowerShellCommandRunnerTests.cs @@ -0,0 +1,48 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Portfolio; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioPowerShellCommandRunnerTests +{ + [Fact] + public async Task RunCommandAsync_BuildsSharedPowerShellCommandRequest() + { + PowerShellRunRequest? captured = null; + var runner = new PowerShellCommandRunner(new StubPowerShellRunner(request => { + captured = request; + return new PowerShellRunResult(0, "done", string.Empty, "pwsh"); + })); + var environmentVariables = new Dictionary { + ["PF_STUDIO"] = "1" + }; + + var result = await runner.RunCommandAsync( + workingDirectory: @"C:\repo", + script: "Get-ChildItem", + environmentVariables: environmentVariables, + cancellationToken: CancellationToken.None); + + Assert.NotNull(captured); + Assert.Equal(PowerShellInvocationMode.Command, captured!.InvocationMode); + Assert.Equal(@"C:\repo", captured.WorkingDirectory); + Assert.Equal("Get-ChildItem", captured.CommandText); + Assert.Equal(environmentVariables, captured.EnvironmentVariables); + Assert.Equal(!OperatingSystem.IsWindows(), captured.PreferPwsh); + Assert.Equal(0, result.ExitCode); + Assert.Equal("done", result.StandardOutput); + } + + private sealed class StubPowerShellRunner : IPowerShellRunner + { + private readonly Func _execute; + + public StubPowerShellRunner(Func execute) + { + _execute = execute; + } + + public PowerShellRunResult Run(PowerShellRunRequest request) + => _execute(request); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioQueueCheckpointSerializerTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioQueueCheckpointSerializerTests.cs new file mode 100644 index 00000000..3393bc3b --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioQueueCheckpointSerializerTests.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioQueueCheckpointSerializerTests +{ + [Fact] + public void TryRead_WithMatchingCheckpointKey_DeserializesTypedPayload() + { + var serializer = new ReleaseQueueCheckpointSerializer(); + var payload = new ReleaseBuildExecutionResult( + RootPath: @"C:\Support\GitHub\Testimo", + Succeeded: true, + Summary: "Build completed.", + DurationSeconds: 1.5, + AdapterResults: []); + var queueItem = new ReleaseQueueItem( + RootPath: payload.RootPath, + RepositoryName: "Testimo", + RepositoryKind: ReleaseRepositoryKind.Module, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Sign, + Status: ReleaseQueueItemStatus.WaitingApproval, + Summary: "USB approval required.", + CheckpointKey: "sign.waiting.usb", + CheckpointStateJson: JsonSerializer.Serialize(payload), + UpdatedAtUtc: DateTimeOffset.UtcNow); + + var result = serializer.TryRead(queueItem, "sign.waiting.usb"); + + Assert.NotNull(result); + Assert.Equal(payload.RootPath, result!.RootPath); + Assert.Equal(payload.Summary, result.Summary); + } + + [Fact] + public void SerializeTransition_WritesExpectedEnvelope() + { + var serializer = new ReleaseQueueCheckpointSerializer(); + var timestamp = DateTimeOffset.UtcNow; + + var json = serializer.SerializeTransition("Build", "Sign", timestamp); + var payload = JsonSerializer.Deserialize>(json); + + Assert.NotNull(payload); + Assert.Equal("Build", payload!["from"]); + Assert.Equal("Sign", payload["to"]); + Assert.Equal(timestamp.ToString("O"), payload["updatedAtUtc"]); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioQueueCommandStateServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioQueueCommandStateServiceTests.cs new file mode 100644 index 00000000..02c0ca2b --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioQueueCommandStateServiceTests.cs @@ -0,0 +1,60 @@ +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Domain.Signing; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioQueueCommandStateServiceTests +{ + [Fact] + public async Task LoadResultAsync_LoadsPersistedReceiptsForSession() + { + var databasePath = Path.Combine(Path.GetTempPath(), "PowerForgeStudio", Guid.NewGuid().ToString("N"), "queue.db"); + + try + { + var service = new ReleaseQueueCommandStateService(); + var stateDatabase = await service.OpenDatabaseAsync(databasePath); + var queueItem = new ReleaseQueueItem( + RootPath: @"C:\Support\GitHub\DbaClientX", + RepositoryName: "DbaClientX", + RepositoryKind: ReleaseRepositoryKind.Mixed, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Sign, + Status: ReleaseQueueItemStatus.WaitingApproval, + Summary: "Waiting.", + CheckpointKey: "sign.waiting.usb", + CheckpointStateJson: "{}", + UpdatedAtUtc: DateTimeOffset.UtcNow); + var session = ReleaseQueueSessionFactory.Create(@"C:\Support\GitHub", [queueItem], DateTimeOffset.UtcNow); + await stateDatabase.PersistQueueSessionAsync(session); + await stateDatabase.PersistSigningReceiptsAsync(session.SessionId, [ + new ReleaseSigningReceipt( + RootPath: queueItem.RootPath, + RepositoryName: queueItem.RepositoryName, + AdapterKind: "ProjectBuild", + ArtifactPath: @"C:\Support\GitHub\DbaClientX\Artifact.nupkg", + ArtifactKind: "File", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Signed.", + SignedAtUtc: DateTimeOffset.UtcNow) + ]); + + var result = await service.LoadResultAsync(stateDatabase, session, changed: true, message: "Loaded."); + + Assert.True(result.Changed); + Assert.NotNull(result.QueueSession); + Assert.Single(result.SigningReceipts); + } + finally + { + var directory = Path.GetDirectoryName(databasePath); + if (!string.IsNullOrWhiteSpace(directory) && Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioQueueExecutionResultFactoryTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioQueueExecutionResultFactoryTests.cs new file mode 100644 index 00000000..472dc611 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioQueueExecutionResultFactoryTests.cs @@ -0,0 +1,71 @@ +using PowerForgeStudio.Domain.Publish; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Domain.Verification; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioQueueExecutionResultFactoryTests +{ + [Fact] + public void CreatePublishResult_UsesPublishedSkippedFailedCounts() + { + var queueItem = new ReleaseQueueItem( + RootPath: @"C:\Support\GitHub\Testimo", + RepositoryName: "Testimo", + RepositoryKind: Domain.Catalog.ReleaseRepositoryKind.Module, + WorkspaceKind: Domain.Catalog.ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Publish, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready.", + CheckpointKey: "publish.ready", + CheckpointStateJson: "{}", + UpdatedAtUtc: DateTimeOffset.UtcNow); + var receipts = new[] { + ReleaseQueueReceiptFactory.CreatePublishReceipt(queueItem.RootPath, queueItem.RepositoryName, "ModuleBuild", "Module publish", "PowerShellRepository", "PSGallery", ReleasePublishReceiptStatus.Published, "Published."), + ReleaseQueueReceiptFactory.CreatePublishReceipt(queueItem.RootPath, queueItem.RepositoryName, "ModuleBuild", "GitHub release", "GitHub", "EvotecIT/Testimo", ReleasePublishReceiptStatus.Skipped, "Skipped."), + ReleaseQueueReceiptFactory.CreatePublishReceipt(queueItem.RootPath, queueItem.RepositoryName, "ModuleBuild", "NuGet publish", "NuGet", "nuget.org", ReleasePublishReceiptStatus.Failed, "Failed.") + }; + + var result = ReleaseQueueExecutionResultFactory.CreatePublishResult(queueItem, receipts); + + Assert.False(result.Succeeded); + Assert.Equal("Publish completed with 1 published, 1 skipped, and 1 failed target(s).", result.Summary); + } + + [Fact] + public void CreateVerificationResult_UsesVerifiedSkippedCountsWhenNoFailures() + { + var queueItem = new ReleaseQueueItem( + RootPath: @"C:\Support\GitHub\DbaClientX", + RepositoryName: "DbaClientX", + RepositoryKind: Domain.Catalog.ReleaseRepositoryKind.Mixed, + WorkspaceKind: Domain.Catalog.ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Verify, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready.", + CheckpointKey: "verify.ready", + CheckpointStateJson: "{}", + UpdatedAtUtc: DateTimeOffset.UtcNow); + var publishReceipt = ReleaseQueueReceiptFactory.CreatePublishReceipt( + queueItem.RootPath, + queueItem.RepositoryName, + "ProjectBuild", + "GitHub release", + "GitHub", + "EvotecIT/DbaClientX", + ReleasePublishReceiptStatus.Published, + "Published."); + var receipts = new[] { + ReleaseQueueReceiptFactory.CreateVerificationReceipt(publishReceipt, ReleaseVerificationReceiptStatus.Verified, "Verified."), + ReleaseQueueReceiptFactory.CreateVerificationReceipt(publishReceipt, ReleaseVerificationReceiptStatus.Skipped, "Skipped.") + }; + + var result = ReleaseQueueExecutionResultFactory.CreateVerificationResult(queueItem, receipts); + + Assert.True(result.Succeeded); + Assert.Equal("Verification completed with 1 verified and 1 skipped check(s).", result.Summary); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioQueueItemFactoryTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioQueueItemFactoryTests.cs new file mode 100644 index 00000000..f753903b --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioQueueItemFactoryTests.cs @@ -0,0 +1,44 @@ +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Portfolio; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioQueueItemFactoryTests +{ + [Fact] + public void CreateFromPortfolioItem_FailedPlan_BlocksPrepareStage() + { + var item = new RepositoryPortfolioItem( + new RepositoryCatalogEntry( + Name: "DbaClientX", + RootPath: @"C:\Support\GitHub\DbaClientX", + RepositoryKind: ReleaseRepositoryKind.Library, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + ModuleBuildScriptPath: null, + ProjectBuildScriptPath: @"C:\Support\GitHub\DbaClientX\Build\Build-Project.ps1", + IsWorktree: false, + HasWebsiteSignals: false), + new RepositoryGitSnapshot(true, "main", "origin/main", 0, 0, 0, 0), + new RepositoryReadiness(RepositoryReadinessKind.Ready, "Ready."), + PlanResults: [ + new RepositoryPlanResult( + RepositoryPlanAdapterKind.ProjectPlan, + RepositoryPlanStatus.Failed, + "Plan failed.", + PlanPath: null, + ExitCode: 1, + DurationSeconds: 1.0, + OutputTail: null, + ErrorTail: "boom") + ]); + + var queueItem = ReleaseQueueItemFactory.CreateFromPortfolioItem(item, 1, DateTimeOffset.UtcNow); + + Assert.Equal(ReleaseQueueStage.Prepare, queueItem.Stage); + Assert.Equal(ReleaseQueueItemStatus.Blocked, queueItem.Status); + Assert.Equal("prepare.blocked.plan", queueItem.CheckpointKey); + Assert.Equal("boom", queueItem.Summary); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioQueueItemTransitionFactoryTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioQueueItemTransitionFactoryTests.cs new file mode 100644 index 00000000..c4aeb0c7 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioQueueItemTransitionFactoryTests.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioQueueItemTransitionFactoryTests +{ + [Fact] + public void CreateTransition_WritesTransitionCheckpointAndState() + { + var item = new ReleaseQueueItem( + RootPath: @"C:\Support\GitHub\Testimo", + RepositoryName: "Testimo", + RepositoryKind: ReleaseRepositoryKind.Module, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Build, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready.", + CheckpointKey: "build.ready", + CheckpointStateJson: null, + UpdatedAtUtc: DateTimeOffset.UtcNow); + var factory = new ReleaseQueueItemTransitionFactory(); + var timestamp = DateTimeOffset.UtcNow; + + var updated = factory.CreateTransition( + item, + fromStage: "Build", + targetStage: ReleaseQueueStage.Sign, + targetStatus: ReleaseQueueItemStatus.WaitingApproval, + summary: "Moved.", + checkpointKey: "sign.waiting.usb", + timestamp: timestamp); + + Assert.Equal(ReleaseQueueStage.Sign, updated.Stage); + Assert.Equal(ReleaseQueueItemStatus.WaitingApproval, updated.Status); + Assert.Equal("sign.waiting.usb", updated.CheckpointKey); + var payload = JsonSerializer.Deserialize>(updated.CheckpointStateJson!); + Assert.Equal("Build", payload!["from"]); + Assert.Equal("Sign", payload["to"]); + } + + [Fact] + public void CreateCheckpointUpdate_SerializesPayload() + { + var item = new ReleaseQueueItem( + RootPath: @"C:\Support\GitHub\DbaClientX", + RepositoryName: "DbaClientX", + RepositoryKind: ReleaseRepositoryKind.Mixed, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Publish, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready.", + CheckpointKey: "publish.ready", + CheckpointStateJson: null, + UpdatedAtUtc: DateTimeOffset.UtcNow); + var factory = new ReleaseQueueItemTransitionFactory(); + var payload = new ReleaseBuildExecutionResult( + RootPath: item.RootPath, + Succeeded: true, + Summary: "Build completed.", + DurationSeconds: 1.2, + AdapterResults: []); + + var updated = factory.CreateCheckpointUpdate( + item, + targetStage: ReleaseQueueStage.Sign, + targetStatus: ReleaseQueueItemStatus.WaitingApproval, + summary: payload.Summary, + checkpointKey: "sign.waiting.usb", + checkpoint: payload, + timestamp: DateTimeOffset.UtcNow); + + var deserialized = JsonSerializer.Deserialize(updated.CheckpointStateJson!); + Assert.Equal("Build completed.", deserialized!.Summary); + Assert.Equal(ReleaseQueueStage.Sign, updated.Stage); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioQueueReceiptFactoryTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioQueueReceiptFactoryTests.cs new file mode 100644 index 00000000..9cc57769 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioQueueReceiptFactoryTests.cs @@ -0,0 +1,49 @@ +using PowerForgeStudio.Domain.Publish; +using PowerForgeStudio.Domain.Verification; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioQueueReceiptFactoryTests +{ + [Fact] + public void FailedPublishReceipt_UsesTargetNameAsFallbackTargetKind() + { + var receipt = ReleaseQueueReceiptFactory.FailedPublishReceipt( + rootPath: @"C:\Support\GitHub\Testimo", + repositoryName: "Testimo", + adapterKind: "ProjectBuild", + targetName: "NuGet publish", + destination: "nuget.org", + summary: "API key missing."); + + Assert.Equal("NuGet publish", receipt.TargetName); + Assert.Equal("NuGet publish", receipt.TargetKind); + Assert.Equal(ReleasePublishReceiptStatus.Failed, receipt.Status); + } + + [Fact] + public void CreateVerificationReceipt_MapsPublishReceiptIdentity() + { + var publishReceipt = new ReleasePublishReceipt( + RootPath: @"C:\Support\GitHub\DbaClientX", + RepositoryName: "DbaClientX", + AdapterKind: "ProjectBuild", + TargetName: "GitHub release", + TargetKind: "GitHub", + Destination: "EvotecIT/DbaClientX", + SourcePath: @"C:\Support\GitHub\DbaClientX\Artefacts\ProjectBuild\release.zip", + Status: ReleasePublishReceiptStatus.Published, + Summary: "Published.", + PublishedAtUtc: DateTimeOffset.UtcNow); + + var verificationReceipt = ReleaseQueueReceiptFactory.CreateVerificationReceipt( + publishReceipt, + ReleaseVerificationReceiptStatus.Verified, + "Verified."); + + Assert.Equal(publishReceipt.RootPath, verificationReceipt.RootPath); + Assert.Equal(publishReceipt.TargetKind, verificationReceipt.TargetKind); + Assert.Equal(ReleaseVerificationReceiptStatus.Verified, verificationReceipt.Status); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioQueueSessionFactoryTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioQueueSessionFactoryTests.cs new file mode 100644 index 00000000..7a1feb26 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioQueueSessionFactoryTests.cs @@ -0,0 +1,59 @@ +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioQueueSessionFactoryTests +{ + [Fact] + public void Create_ComputesSummaryAndPreservesScope() + { + var items = new[] { + CreateItem("a", ReleaseQueueStage.Build, ReleaseQueueItemStatus.ReadyToRun), + CreateItem("b", ReleaseQueueStage.Sign, ReleaseQueueItemStatus.WaitingApproval) + }; + + var session = ReleaseQueueSessionFactory.Create( + workspaceRoot: @"C:\Support\GitHub", + items: items, + createdAtUtc: DateTimeOffset.UtcNow, + scopeKey: "family-a", + scopeDisplayName: "Family A"); + + Assert.Equal("family-a", session.ScopeKey); + Assert.Equal("Family A", session.ScopeDisplayName); + Assert.Equal(1, session.Summary.BuildReadyItems); + Assert.Equal(1, session.Summary.WaitingApprovalItems); + } + + [Fact] + public void WithItems_RecomputesSummary() + { + var session = ReleaseQueueSessionFactory.Create( + workspaceRoot: @"C:\Support\GitHub", + items: [CreateItem("a", ReleaseQueueStage.Build, ReleaseQueueItemStatus.ReadyToRun)], + createdAtUtc: DateTimeOffset.UtcNow); + var updated = ReleaseQueueSessionFactory.WithItems( + session, + [CreateItem("b", ReleaseQueueStage.Verify, ReleaseQueueItemStatus.ReadyToRun)]); + + Assert.Equal(session.SessionId, updated.SessionId); + Assert.Equal(0, updated.Summary.BuildReadyItems); + Assert.Equal(1, updated.Summary.VerificationReadyItems); + } + + private static ReleaseQueueItem CreateItem(string name, ReleaseQueueStage stage, ReleaseQueueItemStatus status) + => new( + RootPath: $@"C:\Support\GitHub\{name}", + RepositoryName: name, + RepositoryKind: ReleaseRepositoryKind.Mixed, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: stage, + Status: status, + Summary: "test", + CheckpointKey: "test", + CheckpointStateJson: null, + UpdatedAtUtc: DateTimeOffset.UtcNow); +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioQueueSummaryFactoryTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioQueueSummaryFactoryTests.cs new file mode 100644 index 00000000..32f9c75c --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioQueueSummaryFactoryTests.cs @@ -0,0 +1,43 @@ +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioQueueSummaryFactoryTests +{ + [Fact] + public void Create_ComputesStageAndStatusCounters() + { + var items = new[] { + CreateItem("a", ReleaseQueueStage.Build, ReleaseQueueItemStatus.ReadyToRun), + CreateItem("b", ReleaseQueueStage.Prepare, ReleaseQueueItemStatus.Pending), + CreateItem("c", ReleaseQueueStage.Sign, ReleaseQueueItemStatus.WaitingApproval), + CreateItem("d", ReleaseQueueStage.Build, ReleaseQueueItemStatus.Failed), + CreateItem("e", ReleaseQueueStage.Verify, ReleaseQueueItemStatus.ReadyToRun) + }; + + var summary = ReleaseQueueSummaryFactory.Create(items); + + Assert.Equal(5, summary.TotalItems); + Assert.Equal(1, summary.BuildReadyItems); + Assert.Equal(1, summary.PreparePendingItems); + Assert.Equal(1, summary.WaitingApprovalItems); + Assert.Equal(1, summary.BlockedItems); + Assert.Equal(1, summary.VerificationReadyItems); + } + + private static ReleaseQueueItem CreateItem(string name, ReleaseQueueStage stage, ReleaseQueueItemStatus status) + => new( + RootPath: $@"C:\Support\GitHub\{name}", + RepositoryName: name, + RepositoryKind: ReleaseRepositoryKind.Mixed, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: stage, + Status: status, + Summary: "test", + CheckpointKey: "test", + CheckpointStateJson: null, + UpdatedAtUtc: DateTimeOffset.UtcNow); +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioQueueTargetProjectionServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioQueueTargetProjectionServiceTests.cs new file mode 100644 index 00000000..c020d32f --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioQueueTargetProjectionServiceTests.cs @@ -0,0 +1,41 @@ +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioQueueTargetProjectionServiceTests +{ + [Fact] + public void BuildTargets_FiltersByStageAndStatusBeforeProjecting() + { + var service = new ReleaseQueueTargetProjectionService(); + var matching = new ReleaseQueueItem( + RootPath: @"C:\Support\GitHub\Testimo", + RepositoryName: "Testimo", + RepositoryKind: ReleaseRepositoryKind.Module, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Publish, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready.", + CheckpointKey: "publish.ready", + CheckpointStateJson: "{}", + UpdatedAtUtc: DateTimeOffset.UtcNow); + var ignored = matching with { + RootPath = @"C:\Support\GitHub\Other", + RepositoryName = "Other", + Stage = ReleaseQueueStage.Verify + }; + + var targets = service.BuildTargets( + [matching, ignored], + ReleaseQueueStage.Publish, + item => item.RootPath == matching.RootPath ? "checkpoint" : null, + static (item, checkpoint) => [ $"{item.RepositoryName}:{checkpoint}" ], + static target => target); + + var target = Assert.Single(targets); + Assert.Equal("Testimo:checkpoint", target); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs new file mode 100644 index 00000000..c4592be9 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseBuildExecutionServiceTests.cs @@ -0,0 +1,105 @@ +using PowerForge; +using PowerForgeStudio.Orchestrator.Catalog; +using PowerForgeStudio.Orchestrator.Portfolio; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioReleaseBuildExecutionServiceTests +{ + [Fact] + public async Task ExecuteAsync_UsesSharedProjectBuildHostServiceForProjectBuilds() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LibraryRepo"); + var buildDirectory = scope.CreateDirectory(Path.Combine("LibraryRepo", "Build")); + var buildScriptPath = Path.Combine(buildDirectory, "Build-Project.ps1"); + var configPath = Path.Combine(buildDirectory, "project.build.json"); + var outputDirectory = Path.Combine(repositoryRoot, "artifacts", "packages"); + + File.WriteAllText(buildScriptPath, "# test"); + File.WriteAllText( + configPath, + """ + { + "RootPath": "..", + "OutputPath": "artifacts/packages", + "Build": true + } + """); + + var callIndex = 0; + var projectBuildHostService = new ProjectBuildHostService( + new NullLogger(), + executeRelease: spec => + { + callIndex++; + if (callIndex == 1) + { + Assert.True(spec.WhatIf); + return new DotNetRepositoryReleaseResult { Success = true }; + } + + Assert.False(spec.WhatIf); + Directory.CreateDirectory(outputDirectory); + var packagePath = Path.Combine(outputDirectory, "LibraryRepo.1.0.0.nupkg"); + File.WriteAllText(packagePath, "pkg"); + return new DotNetRepositoryReleaseResult { + Success = true, + Projects = { + new DotNetRepositoryProjectResult { + ProjectName = "LibraryRepo", + IsPackable = true, + NewVersion = "1.0.0", + Packages = { packagePath } + } + } + }; + }, + publishGitHub: null, + validateGitHubPreflight: null); + var service = new ReleaseBuildExecutionService( + new RepositoryCatalogScanner(), + projectBuildHostService, + new ProjectBuildCommandHostService(new ThrowingPowerShellRunner()), + new ModuleBuildHostService(new ThrowingPowerShellRunner())); + + var result = await service.ExecuteAsync(repositoryRoot); + + Assert.True(result.Succeeded); + Assert.Equal(2, callIndex); + var adapter = Assert.Single(result.AdapterResults); + Assert.Equal(ReleaseBuildAdapterKind.ProjectBuild, adapter.AdapterKind); + Assert.Contains(outputDirectory, adapter.ArtifactDirectories); + Assert.Contains(adapter.ArtifactFiles, path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)); + } + + private sealed class TemporaryDirectoryScope : IDisposable + { + public TemporaryDirectoryScope() + { + RootPath = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(RootPath); + } + + public string RootPath { get; } + + public string CreateDirectory(string relativePath) + { + var path = Path.Combine(RootPath, relativePath); + Directory.CreateDirectory(path); + return path; + } + + public void Dispose() + { + try { Directory.Delete(RootPath, recursive: true); } catch { } + } + } + + private sealed class ThrowingPowerShellRunner : IPowerShellRunner + { + public PowerShellRunResult Run(PowerShellRunRequest request) + => throw new InvalidOperationException("PowerShell should not be used for project builds when shared host service is available."); + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.cs new file mode 100644 index 00000000..be962bf3 --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleasePublishExecutionServiceTests.cs @@ -0,0 +1,613 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using PowerForge; +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Publish; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Domain.Signing; +using PowerForgeStudio.Orchestrator.Catalog; +using PowerForgeStudio.Orchestrator.Portfolio; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioReleasePublishExecutionServiceTests +{ + [Fact] + public void BuildPendingTargets_PublishReadyItem_ReturnsGroupedTargetsFromSigningCheckpoint() + { + var repositoryRoot = @"C:\Support\GitHub\PSPublishModule"; + var signingResult = new ReleaseSigningExecutionResult( + RootPath: repositoryRoot, + Succeeded: true, + Summary: "Signing completed.", + SourceCheckpointStateJson: "{}", + Receipts: [ + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ProjectBuild.ToString(), + ArtifactPath: Path.Combine(repositoryRoot, "Artefacts", "ProjectBuild", "Package.1.0.0.nupkg"), + ArtifactKind: "File", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Signed.", + SignedAtUtc: DateTimeOffset.UtcNow), + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ProjectBuild.ToString(), + ArtifactPath: Path.Combine(repositoryRoot, "Artefacts", "ProjectBuild", "Package.1.0.0.zip"), + ArtifactKind: "File", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Signed.", + SignedAtUtc: DateTimeOffset.UtcNow), + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ModuleBuild.ToString(), + ArtifactPath: Path.Combine(repositoryRoot, "Artefacts", "Packed", "PSPublishModule"), + ArtifactKind: "Directory", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Signed.", + SignedAtUtc: DateTimeOffset.UtcNow) + ]); + + var queueItem = new ReleaseQueueItem( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + RepositoryKind: ReleaseRepositoryKind.Mixed, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Publish, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready for publish.", + CheckpointKey: "publish.ready", + CheckpointStateJson: JsonSerializer.Serialize(signingResult), + UpdatedAtUtc: DateTimeOffset.UtcNow); + + var service = new ReleasePublishExecutionService(); + var targets = service.BuildPendingTargets([queueItem]); + + Assert.Equal(3, targets.Count); + Assert.Contains(targets, target => target.TargetKind == "NuGet"); + Assert.Contains(targets, target => target.TargetKind == "GitHub"); + Assert.Contains(targets, target => target.TargetKind == "PowerShellRepository"); + } + + [Fact] + public async Task ExecuteAsync_ProjectNuGetPublish_UsesSharedDotNetNuGetClient() + { + var repositoryRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForgeStudio.Tests", Guid.NewGuid().ToString("N"))).FullName; + var buildDirectory = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Build")).FullName; + File.WriteAllText(Path.Combine(buildDirectory, "Build-Project.ps1"), "# build"); + + var packagePath = Path.Combine(repositoryRoot, "Artifacts", "Package.1.0.0.nupkg"); + Directory.CreateDirectory(Path.GetDirectoryName(packagePath)!); + File.WriteAllText(packagePath, "package"); + + File.WriteAllText( + Path.Combine(buildDirectory, "project.build.json"), + """ + { + "PublishNuget": true, + "PublishSource": "https://api.nuget.org/v3/index.json", + "PublishApiKey": "secret" + } + """); + + var signingResult = new ReleaseSigningExecutionResult( + RootPath: repositoryRoot, + Succeeded: true, + Summary: "Signing completed.", + SourceCheckpointStateJson: null, + Receipts: [ + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ProjectBuild.ToString(), + ArtifactPath: packagePath, + ArtifactKind: "File", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Package signed.", + SignedAtUtc: DateTimeOffset.UtcNow) + ]); + + var queueItem = new ReleaseQueueItem( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + RepositoryKind: ReleaseRepositoryKind.Library, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Publish, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready for publish.", + CheckpointKey: "publish.ready", + CheckpointStateJson: JsonSerializer.Serialize(signingResult), + UpdatedAtUtc: DateTimeOffset.UtcNow); + + DotNetNuGetPushRequest? captured = null; + var service = new ReleasePublishExecutionService( + new RepositoryCatalogScanner(), + new ModuleBuildHostService(), + new ProjectBuildHostService(), + new ProjectBuildCommandHostService(), + new ProjectBuildPublishHostService(), + (request, _) => { + captured = request; + return Task.FromResult(new DotNetNuGetPushResult(0, "published", string.Empty, "dotnet", TimeSpan.Zero, timedOut: false, errorMessage: null)); + }); + + try + { + using var _ = new EnvironmentScope() + .Set("RELEASE_OPS_STUDIO_ENABLE_PUBLISH", "true"); + + var result = await service.ExecuteAsync(queueItem); + + Assert.True( + result.Succeeded, + $"{result.Summary} | {string.Join(" | ", result.Receipts.Select(receipt => $"{receipt.TargetName}:{receipt.Status}:{receipt.Summary}"))}"); + Assert.NotNull(captured); + Assert.Equal(packagePath, captured!.PackagePath); + Assert.Equal("secret", captured.ApiKey); + Assert.Equal("https://api.nuget.org/v3/index.json", captured.Source); + Assert.Equal(Path.GetDirectoryName(packagePath), captured.WorkingDirectory); + var receipt = Assert.Single(result.Receipts); + Assert.Equal(Domain.Publish.ReleasePublishReceiptStatus.Published, receipt.Status); + Assert.Contains("dotnet nuget push", receipt.Summary, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { Directory.Delete(repositoryRoot, recursive: true); } catch { } + } + } + + [Fact] + public async Task ExecuteAsync_ProjectGitHubPublish_UsesSharedProjectBuildHostPlan() + { + var repositoryRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForgeStudio.Tests", Guid.NewGuid().ToString("N"))).FullName; + var buildDirectory = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Build")).FullName; + File.WriteAllText(Path.Combine(buildDirectory, "Build-Project.ps1"), "# build"); + + var zipPath = Path.Combine(repositoryRoot, "Artifacts", "ProjectBuild", "PSPublishModule.1.2.3.zip"); + Directory.CreateDirectory(Path.GetDirectoryName(zipPath)!); + File.WriteAllText(zipPath, "zip"); + + File.WriteAllText( + Path.Combine(buildDirectory, "project.build.json"), + """ + { + "PublishGitHub": true, + // the shared host config reader should resolve this from the environment + "GitHubAccessTokenEnvName": "PFGH_TOKEN", + "GitHubUsername": "EvotecIT", + "GitHubRepositoryName": "PSPublishModule", + "GitHubGenerateReleaseNotes": true, + } + """); + + var signingResult = new ReleaseSigningExecutionResult( + RootPath: repositoryRoot, + Succeeded: true, + Summary: "Signing completed.", + SourceCheckpointStateJson: null, + Receipts: [ + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ProjectBuild.ToString(), + ArtifactPath: zipPath, + ArtifactKind: "File", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Asset signed.", + SignedAtUtc: DateTimeOffset.UtcNow) + ]); + + var queueItem = new ReleaseQueueItem( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + RepositoryKind: ReleaseRepositoryKind.Library, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Publish, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready for publish.", + CheckpointKey: "publish.ready", + CheckpointStateJson: JsonSerializer.Serialize(signingResult), + UpdatedAtUtc: DateTimeOffset.UtcNow); + + ProjectBuildGitHubPublishRequest? captured = null; + var projectBuildHostService = new ProjectBuildHostService( + new NullLogger(), + executeRelease: spec => new DotNetRepositoryReleaseResult { + Success = true, + Projects = { + new DotNetRepositoryProjectResult { + ProjectName = "PSPublishModule", + IsPackable = true, + NewVersion = "1.2.3", + ReleaseZipPath = zipPath + } + } + }, + publishGitHub: null, + validateGitHubPreflight: null); + var projectBuildPublishHostService = new ProjectBuildPublishHostService( + new NullLogger(), + request => { + captured = request; + return new ProjectBuildGitHubPublishSummary { + Success = true, + SummaryTag = "v1.2.3", + SummaryReleaseUrl = "https://github.com/EvotecIT/PSPublishModule/releases/tag/v1.2.3", + SummaryAssetsCount = 1 + }; + }); + var service = new ReleasePublishExecutionService( + new RepositoryCatalogScanner(), + new ModuleBuildHostService(), + projectBuildHostService, + new ProjectBuildCommandHostService(new ThrowingPowerShellRunner()), + projectBuildPublishHostService, + (request, _) => Task.FromResult(new DotNetNuGetPushResult(0, "published", string.Empty, "dotnet", TimeSpan.Zero, timedOut: false, errorMessage: null))); + + try + { + using var _ = new EnvironmentScope() + .Set("RELEASE_OPS_STUDIO_ENABLE_PUBLISH", "true") + .Set("PFGH_TOKEN", "token"); + + var result = await service.ExecuteAsync(queueItem); + + Assert.True( + result.Succeeded, + $"{result.Summary} | {string.Join(" | ", result.Receipts.Select(receipt => $"{receipt.TargetName}:{receipt.Status}:{receipt.Summary}"))}"); + Assert.NotNull(captured); + Assert.Equal("EvotecIT", captured!.Owner); + Assert.Equal("PSPublishModule", captured.Repository); + Assert.Equal("token", captured.Token); + Assert.True(captured.GenerateReleaseNotes); + Assert.Equal("Single", captured.ReleaseMode); + Assert.Equal( + zipPath, + Assert.Single(captured.Release.Projects.Select(project => project.ReleaseZipPath), path => !string.IsNullOrWhiteSpace(path))); + var receipt = Assert.Single(result.Receipts); + Assert.Equal(ReleasePublishReceiptStatus.Published, receipt.Status); + Assert.Contains("GitHub release", receipt.Summary, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { Directory.Delete(repositoryRoot, recursive: true); } catch { } + } + } + + [Fact] + public async Task ExecuteAsync_ModuleGitHubPublish_UsesSharedGitHubReleasePublisher() + { + var repositoryRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForgeStudio.Tests", Guid.NewGuid().ToString("N"))).FullName; + var buildDirectory = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Build")).FullName; + File.WriteAllText(Path.Combine(buildDirectory, "Build-Module.ps1"), "# build"); + + var packageDirectory = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Artifacts", "Packed", "PSPublishModule")).FullName; + var manifestPath = Path.Combine(packageDirectory, "PSPublishModule.psd1"); + var zipPath = Path.Combine(repositoryRoot, "Artifacts", "Packed", "PSPublishModule.1.2.3.zip"); + File.WriteAllText( + manifestPath, + """ + @{ + RootModule = 'PSPublishModule.psm1' + ModuleVersion = '1.2.3' + } + """); + File.WriteAllText(zipPath, "zip"); + + var signingResult = new ReleaseSigningExecutionResult( + RootPath: repositoryRoot, + Succeeded: true, + Summary: "Signing completed.", + SourceCheckpointStateJson: null, + Receipts: [ + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ModuleBuild.ToString(), + ArtifactPath: packageDirectory, + ArtifactKind: "Directory", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Package directory signed.", + SignedAtUtc: DateTimeOffset.UtcNow), + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ModuleBuild.ToString(), + ArtifactPath: manifestPath, + ArtifactKind: "File", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Manifest signed.", + SignedAtUtc: DateTimeOffset.UtcNow), + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ModuleBuild.ToString(), + ArtifactPath: zipPath, + ArtifactKind: "File", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Asset signed.", + SignedAtUtc: DateTimeOffset.UtcNow) + ]); + + var queueItem = new ReleaseQueueItem( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + RepositoryKind: ReleaseRepositoryKind.Module, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Publish, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready for publish.", + CheckpointKey: "publish.ready", + CheckpointStateJson: JsonSerializer.Serialize(signingResult), + UpdatedAtUtc: DateTimeOffset.UtcNow); + + GitHubReleasePublishRequest? captured = null; + var moduleRunner = new StubPowerShellRunner((request) => { + if (request.InvocationMode != PowerShellInvocationMode.Command || string.IsNullOrWhiteSpace(request.CommandText)) + return new PowerShellRunResult(1, string.Empty, "Unexpected invocation.", "pwsh"); + + if (request.CommandText.Contains("$targetJson =", StringComparison.Ordinal)) + { + var match = Regex.Match(request.CommandText, "\\$targetJson = '([^']+)'"); + if (!match.Success) + return new PowerShellRunResult(1, string.Empty, "Export path missing.", "pwsh"); + + File.WriteAllText( + match.Groups[1].Value, + """ + { + "Segments": [ + { + "Type": "GitHubNuget", + "Configuration": { + "Destination": "GitHub", + "Enabled": true, + "UserName": "EvotecIT", + "RepositoryName": "PSPublishModule", + "ApiKey": "token", + "GenerateReleaseNotes": true + } + } + ] + } + """); + + return new PowerShellRunResult(0, string.Empty, string.Empty, "pwsh"); + } + + return new PowerShellRunResult(1, string.Empty, "Unexpected command.", "pwsh"); + }); + var service = new ReleasePublishExecutionService( + new RepositoryCatalogScanner(), + new ModuleBuildHostService(moduleRunner), + new ProjectBuildHostService(), + new ProjectBuildCommandHostService(), + new ProjectBuildPublishHostService(), + (request, _) => Task.FromResult(new DotNetNuGetPushResult(0, "published", string.Empty, "dotnet", TimeSpan.Zero, timedOut: false, errorMessage: null)), + (request, _) => { + captured = request; + return Task.FromResult(new GitHubReleasePublishResult { + Succeeded = true, + ReleaseCreationSucceeded = true, + AllAssetUploadsSucceeded = true, + HtmlUrl = "https://github.com/EvotecIT/PSPublishModule/releases/tag/v1.2.3" + }); + }); + + try + { + using var _ = new EnvironmentScope() + .Set("RELEASE_OPS_STUDIO_ENABLE_PUBLISH", "true"); + + var result = await service.ExecuteAsync(queueItem); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.Equal("EvotecIT", captured!.Owner); + Assert.Equal("PSPublishModule", captured.Repository); + Assert.Equal("token", captured.Token); + Assert.Equal("v1.2.3", captured.TagName); + Assert.Equal("v1.2.3", captured.ReleaseName); + Assert.True(captured.GenerateReleaseNotes); + Assert.Equal([zipPath], captured.AssetFilePaths); + var receipt = Assert.Single(result.Receipts); + Assert.Equal(ReleasePublishReceiptStatus.Published, receipt.Status); + Assert.Contains("GitHub release v1.2.3 published.", receipt.Summary, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { Directory.Delete(repositoryRoot, recursive: true); } catch { } + } + } + + [Fact] + public async Task ExecuteAsync_ModuleRepositoryPublish_UsesSharedRepositoryPublisher() + { + var repositoryRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForgeStudio.Tests", Guid.NewGuid().ToString("N"))).FullName; + var buildDirectory = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Build")).FullName; + File.WriteAllText(Path.Combine(buildDirectory, "Build-Module.ps1"), "# build"); + + var packageDirectory = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Artifacts", "Packed", "PSPublishModule")).FullName; + var manifestPath = Path.Combine(packageDirectory, "PSPublishModule.psd1"); + File.WriteAllText( + manifestPath, + """ + @{ + RootModule = 'PSPublishModule.psm1' + ModuleVersion = '2.0.0' + PrivateData = @{ + PSData = @{ + Prerelease = 'preview1' + } + } + } + """); + + var signingResult = new ReleaseSigningExecutionResult( + RootPath: repositoryRoot, + Succeeded: true, + Summary: "Signing completed.", + SourceCheckpointStateJson: null, + Receipts: [ + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ModuleBuild.ToString(), + ArtifactPath: packageDirectory, + ArtifactKind: "Directory", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Package directory signed.", + SignedAtUtc: DateTimeOffset.UtcNow), + new ReleaseSigningReceipt( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + AdapterKind: ReleaseBuildAdapterKind.ModuleBuild.ToString(), + ArtifactPath: manifestPath, + ArtifactKind: "File", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Manifest signed.", + SignedAtUtc: DateTimeOffset.UtcNow) + ]); + + var queueItem = new ReleaseQueueItem( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + RepositoryKind: ReleaseRepositoryKind.Module, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Publish, + Status: ReleaseQueueItemStatus.ReadyToRun, + Summary: "Ready for publish.", + CheckpointKey: "publish.ready", + CheckpointStateJson: JsonSerializer.Serialize(signingResult), + UpdatedAtUtc: DateTimeOffset.UtcNow); + + RepositoryPublishRequest? captured = null; + var moduleRunner = new StubPowerShellRunner((request) => { + if (request.InvocationMode != PowerShellInvocationMode.Command || string.IsNullOrWhiteSpace(request.CommandText)) + return new PowerShellRunResult(1, string.Empty, "Unexpected invocation.", "pwsh"); + + if (request.CommandText.Contains("$targetJson =", StringComparison.Ordinal)) + { + var match = Regex.Match(request.CommandText, "\\$targetJson = '([^']+)'"); + if (!match.Success) + return new PowerShellRunResult(1, string.Empty, "Export path missing.", "pwsh"); + + File.WriteAllText( + match.Groups[1].Value, + """ + { + "Segments": [ + { + "Type": "GalleryNuget", + "Configuration": { + "Destination": "PowerShellGallery", + "Enabled": true, + "Tool": "PSResourceGet", + "ApiKey": "gallery-key", + "RepositoryName": "PSGallery" + } + } + ] + } + """); + + return new PowerShellRunResult(0, string.Empty, string.Empty, "pwsh"); + } + + return new PowerShellRunResult(1, string.Empty, "Unexpected command.", "pwsh"); + }); + var service = new ReleasePublishExecutionService( + new RepositoryCatalogScanner(), + new ModuleBuildHostService(moduleRunner), + new ProjectBuildHostService(), + new ProjectBuildCommandHostService(), + new ProjectBuildPublishHostService(), + (request, _) => Task.FromResult(new DotNetNuGetPushResult(0, "published", string.Empty, "dotnet", TimeSpan.Zero, timedOut: false, errorMessage: null)), + publishRepositoryAsync: (request, _) => { + captured = request; + return Task.FromResult(new RepositoryPublishResult( + path: request.Path, + isNupkg: request.IsNupkg, + repositoryName: request.RepositoryName ?? "PSGallery", + tool: request.Tool, + repositoryCreated: false, + repositoryUnregistered: false)); + }); + + try + { + using var _ = new EnvironmentScope() + .Set("RELEASE_OPS_STUDIO_ENABLE_PUBLISH", "true"); + + var result = await service.ExecuteAsync(queueItem); + + Assert.True(result.Succeeded); + Assert.True( + captured is not null, + string.Join(" | ", result.Receipts.Select(receipt => $"{receipt.TargetKind}:{receipt.Status}:{receipt.Summary}"))); + Assert.Equal(packageDirectory, captured!.Path); + Assert.False(captured.IsNupkg); + Assert.Equal("PSGallery", captured.RepositoryName); + Assert.Equal(PublishTool.PSResourceGet, captured.Tool); + Assert.Equal("gallery-key", captured.ApiKey); + Assert.True(captured.SkipDependenciesCheck); + Assert.False(captured.SkipModuleManifestValidate); + var receipt = Assert.Single(result.Receipts); + Assert.Equal(ReleasePublishReceiptStatus.Published, receipt.Status); + Assert.Contains("PSGallery", receipt.Summary, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PSResourceGet", receipt.Summary, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { Directory.Delete(repositoryRoot, recursive: true); } catch { } + } + } + + private sealed class StubPowerShellRunner : IPowerShellRunner + { + private readonly Func _execute; + + public StubPowerShellRunner(Func execute) + { + _execute = execute; + } + + public PowerShellRunResult Run(PowerShellRunRequest request) + => _execute(request); + } + + private sealed class ThrowingPowerShellRunner : IPowerShellRunner + { + public PowerShellRunResult Run(PowerShellRunRequest request) + => throw new InvalidOperationException("PowerShell should not be used for project publish planning when shared host service is available."); + } + + private sealed class EnvironmentScope : IDisposable + { + private readonly Dictionary _originalValues = new(StringComparer.OrdinalIgnoreCase); + + public EnvironmentScope Set(string name, string? value) + { + if (!_originalValues.ContainsKey(name)) + _originalValues[name] = Environment.GetEnvironmentVariable(name); + + Environment.SetEnvironmentVariable(name, value); + return this; + } + + public void Dispose() + { + foreach (var entry in _originalValues) + Environment.SetEnvironmentVariable(entry.Key, entry.Value); + } + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseQueueCommandServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseQueueCommandServiceTests.cs index 788b8cb7..4c4a068e 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioReleaseQueueCommandServiceTests.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseQueueCommandServiceTests.cs @@ -114,13 +114,7 @@ private static ReleaseQueueSession CreateSession(ReleaseQueueItem item) SessionId: Guid.NewGuid().ToString("N"), WorkspaceRoot: @"C:\Support\GitHub", CreatedAtUtc: DateTimeOffset.UtcNow, - Summary: new ReleaseQueueSummary( - TotalItems: 1, - BuildReadyItems: item.Stage == ReleaseQueueStage.Build && item.Status == ReleaseQueueItemStatus.ReadyToRun ? 1 : 0, - PreparePendingItems: item.Stage == ReleaseQueueStage.Prepare && item.Status == ReleaseQueueItemStatus.Pending ? 1 : 0, - WaitingApprovalItems: item.Status == ReleaseQueueItemStatus.WaitingApproval ? 1 : 0, - BlockedItems: item.Status is ReleaseQueueItemStatus.Blocked or ReleaseQueueItemStatus.Failed ? 1 : 0, - VerificationReadyItems: item.Stage == ReleaseQueueStage.Verify && item.Status == ReleaseQueueItemStatus.ReadyToRun ? 1 : 0), + Summary: ReleaseQueueSummaryFactory.Create([item]), Items: [item]); private static void DeleteParentDirectory(string databasePath) diff --git a/PowerForgeStudio.Tests/PowerForgeStudioReleaseSigningExecutionServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioReleaseSigningExecutionServiceTests.cs new file mode 100644 index 00000000..7533775f --- /dev/null +++ b/PowerForgeStudio.Tests/PowerForgeStudioReleaseSigningExecutionServiceTests.cs @@ -0,0 +1,236 @@ +using System.Text.Json; +using PowerForge; +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Domain.Signing; +using PowerForgeStudio.Orchestrator.Queue; + +namespace PowerForgeStudio.Tests; + +public sealed class PowerForgeStudioReleaseSigningExecutionServiceTests +{ + [Fact] + public async Task ExecuteAsync_NuGetArtifact_UsesSharedDotNetNuGetClient() + { + var repositoryRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForgeStudio.Tests", Guid.NewGuid().ToString("N"))).FullName; + var packagePath = Path.Combine(repositoryRoot, "Artifacts", "Package.1.0.0.nupkg"); + Directory.CreateDirectory(Path.GetDirectoryName(packagePath)!); + File.WriteAllText(packagePath, "package"); + + var buildResult = new ReleaseBuildExecutionResult( + RootPath: repositoryRoot, + Succeeded: true, + Summary: "Build completed.", + DurationSeconds: 1.0, + AdapterResults: [ + new ReleaseBuildAdapterResult( + ReleaseBuildAdapterKind.ProjectBuild, + true, + "Project build completed.", + 0, + 1.0, + [], + [packagePath]) + ]); + + var queueItem = new ReleaseQueueItem( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + RepositoryKind: ReleaseRepositoryKind.Library, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Sign, + Status: ReleaseQueueItemStatus.WaitingApproval, + Summary: "Ready for signing.", + CheckpointKey: "sign.waiting.usb", + CheckpointStateJson: JsonSerializer.Serialize(buildResult), + UpdatedAtUtc: DateTimeOffset.UtcNow); + + DotNetNuGetSignRequest? captured = null; + var service = new ReleaseSigningExecutionService( + new ReleaseBuildCheckpointReader(), + new ReleaseSigningHostSettingsResolver( + getEnvironmentVariable: name => name switch + { + "RELEASE_OPS_STUDIO_SIGN_THUMBPRINT" => "thumb", + "RELEASE_OPS_STUDIO_SIGN_STORE" => "CurrentUser", + "RELEASE_OPS_STUDIO_SIGN_TIMESTAMP_URL" => "http://timestamp.digicert.com", + _ => null + }, + resolveModulePath: () => @"C:\Temp\PSPublishModule.psd1"), + new CertificateFingerprintResolver((_, _) => "ABC123"), + (request, _) => Task.FromResult(new AuthenticodeSigningHostResult { ExitCode = 0 }), + (request, _) => { + captured = request; + return Task.FromResult(new DotNetNuGetSignResult(0, "signed", string.Empty, "dotnet", TimeSpan.Zero, timedOut: false, errorMessage: null)); + }); + + try + { + var result = await service.ExecuteAsync(queueItem); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.Equal(packagePath, captured!.PackagePath); + Assert.Equal("ABC123", captured.CertificateFingerprint); + Assert.Equal("CurrentUser", captured.CertificateStoreLocation); + Assert.Equal(Path.GetDirectoryName(packagePath), captured.WorkingDirectory); + var receipt = Assert.Single(result.Receipts); + Assert.Equal(ReleaseSigningReceiptStatus.Signed, receipt.Status); + Assert.Contains("dotnet nuget sign", receipt.Summary, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { Directory.Delete(repositoryRoot, recursive: true); } catch { } + } + } + + [Fact] + public async Task ExecuteAsync_DirectoryArtifact_UsesSharedAuthenticodeSigningHost() + { + var repositoryRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForgeStudio.Tests", Guid.NewGuid().ToString("N"))).FullName; + var artifactDirectory = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Artifacts", "Module")).FullName; + File.WriteAllText(Path.Combine(artifactDirectory, "PSPublishModule.psd1"), "@{}"); + + var buildResult = new ReleaseBuildExecutionResult( + RootPath: repositoryRoot, + Succeeded: true, + Summary: "Build completed.", + DurationSeconds: 1.0, + AdapterResults: [ + new ReleaseBuildAdapterResult( + ReleaseBuildAdapterKind.ModuleBuild, + true, + "Module build completed.", + 0, + 1.0, + [artifactDirectory], + []) + ]); + + var queueItem = new ReleaseQueueItem( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + RepositoryKind: ReleaseRepositoryKind.Module, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Sign, + Status: ReleaseQueueItemStatus.WaitingApproval, + Summary: "Ready for signing.", + CheckpointKey: "sign.waiting.usb", + CheckpointStateJson: JsonSerializer.Serialize(buildResult), + UpdatedAtUtc: DateTimeOffset.UtcNow); + + AuthenticodeSigningHostRequest? captured = null; + var service = new ReleaseSigningExecutionService( + new ReleaseBuildCheckpointReader(), + new ReleaseSigningHostSettingsResolver( + getEnvironmentVariable: name => name switch + { + "RELEASE_OPS_STUDIO_SIGN_THUMBPRINT" => "thumb", + "RELEASE_OPS_STUDIO_SIGN_STORE" => "CurrentUser", + "RELEASE_OPS_STUDIO_SIGN_TIMESTAMP_URL" => "http://timestamp.digicert.com", + _ => null + }, + resolveModulePath: () => @"C:\Temp\PSPublishModule.psd1"), + new CertificateFingerprintResolver((_, _) => "ABC123"), + (request, _) => { + captured = request; + return Task.FromResult(new AuthenticodeSigningHostResult { + ExitCode = 0, + Duration = TimeSpan.Zero, + StandardOutput = "signed" + }); + }, + (request, _) => Task.FromResult(new DotNetNuGetSignResult(0, "signed", string.Empty, "dotnet", TimeSpan.Zero, timedOut: false, errorMessage: null))); + + try + { + var result = await service.ExecuteAsync(queueItem); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.Equal(artifactDirectory, captured!.SigningPath); + Assert.Contains("*.ps1", captured.IncludePatterns); + Assert.Contains("*.psd1", captured.IncludePatterns); + var receipt = Assert.Single(result.Receipts); + Assert.Equal(ReleaseSigningReceiptStatus.Signed, receipt.Status); + Assert.Contains("Authenticode signing completed", receipt.Summary, StringComparison.OrdinalIgnoreCase); + } + finally + { + try { Directory.Delete(repositoryRoot, recursive: true); } catch { } + } + } + + [Fact] + public async Task ExecuteAsync_UsesSharedSigningSettingsResolver() + { + var repositoryRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForgeStudio.Tests", Guid.NewGuid().ToString("N"))).FullName; + var artifactDirectory = Directory.CreateDirectory(Path.Combine(repositoryRoot, "Artifacts", "Module")).FullName; + File.WriteAllText(Path.Combine(artifactDirectory, "PSPublishModule.psd1"), "@{}"); + + var buildResult = new ReleaseBuildExecutionResult( + RootPath: repositoryRoot, + Succeeded: true, + Summary: "Build completed.", + DurationSeconds: 1.0, + AdapterResults: [ + new ReleaseBuildAdapterResult( + ReleaseBuildAdapterKind.ModuleBuild, + true, + "Module build completed.", + 0, + 1.0, + [artifactDirectory], + [])]); + + var queueItem = new ReleaseQueueItem( + RootPath: repositoryRoot, + RepositoryName: "PSPublishModule", + RepositoryKind: ReleaseRepositoryKind.Module, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + QueueOrder: 1, + Stage: ReleaseQueueStage.Sign, + Status: ReleaseQueueItemStatus.WaitingApproval, + Summary: "Ready for signing.", + CheckpointKey: "sign.waiting.usb", + CheckpointStateJson: JsonSerializer.Serialize(buildResult), + UpdatedAtUtc: DateTimeOffset.UtcNow); + + AuthenticodeSigningHostRequest? captured = null; + var service = new ReleaseSigningExecutionService( + new ReleaseBuildCheckpointReader(), + new ReleaseSigningHostSettingsResolver( + getEnvironmentVariable: name => name switch + { + "RELEASE_OPS_STUDIO_SIGN_THUMBPRINT" => "thumb", + "RELEASE_OPS_STUDIO_SIGN_STORE" => "LocalMachine", + "RELEASE_OPS_STUDIO_SIGN_TIMESTAMP_URL" => "https://timestamp.contoso.test", + _ => null + }, + resolveModulePath: () => @"C:\Resolved\PSPublishModule.psd1"), + new CertificateFingerprintResolver((_, _) => "ABC123"), + (request, _) => { + captured = request; + return Task.FromResult(new AuthenticodeSigningHostResult { ExitCode = 0 }); + }, + (request, _) => Task.FromResult(new DotNetNuGetSignResult(0, "signed", string.Empty, "dotnet", TimeSpan.Zero, timedOut: false, errorMessage: null))); + + try + { + var result = await service.ExecuteAsync(queueItem); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.Equal("thumb", captured!.Thumbprint); + Assert.Equal("LocalMachine", captured.StoreName); + Assert.Equal("https://timestamp.contoso.test", captured.TimeStampServer); + Assert.Equal(@"C:\Resolved\PSPublishModule.psd1", captured.ModulePath); + } + finally + { + try { Directory.Delete(repositoryRoot, recursive: true); } catch { } + } + } +} diff --git a/PowerForgeStudio.Tests/PowerForgeStudioRepositoryGitQuickActionServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioRepositoryGitQuickActionServiceTests.cs index 71bc5835..d5162396 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioRepositoryGitQuickActionServiceTests.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioRepositoryGitQuickActionServiceTests.cs @@ -1,3 +1,4 @@ +using PowerForge; using PowerForgeStudio.Domain.Catalog; using PowerForgeStudio.Domain.Portfolio; using PowerForgeStudio.Orchestrator.Portfolio; @@ -44,6 +45,7 @@ public void BuildActions_FeatureBranchWithGitHubInbox_AddsCompareAndPullRequestL Assert.Contains(actions, action => action.Title == "Open Pull Requests"); Assert.Contains(actions, action => action.Title == "Open Compare View"); Assert.Contains(actions, action => action.Kind == RepositoryGitQuickActionKind.GitCommand && action.Payload == "git status --short --branch"); + Assert.Contains(actions, action => action.GitOperation == RepositoryGitOperationKind.StatusShortBranch); Assert.DoesNotContain(actions, action => action.Payload.StartsWith("git push --set-upstream", StringComparison.OrdinalIgnoreCase)); } @@ -63,12 +65,13 @@ public void BuildActions_DefaultBranchWithoutGitHubInbox_ReturnsOnlySafeLocalCom var actions = _service.BuildActions(repository, remediationSteps); Assert.Contains(actions, action => action.Payload == "git switch -c codex/pspublishmodule-release-flow"); + Assert.Contains(actions, action => action.GitOperation == RepositoryGitOperationKind.CreateBranch); Assert.DoesNotContain(actions, action => action.Payload.StartsWith("git push --set-upstream", StringComparison.OrdinalIgnoreCase)); Assert.All(actions, action => Assert.NotEqual(RepositoryGitQuickActionKind.BrowserUrl, action.Kind)); } [Fact] - public async Task ExecuteAsync_UsesSharedPowerShellRunnerContract() + public async Task ExecuteAsync_UsesSharedGitClientContract() { var action = new RepositoryGitQuickAction( Title: "Inspect current git state", @@ -76,15 +79,21 @@ public async Task ExecuteAsync_UsesSharedPowerShellRunnerContract() Kind: RepositoryGitQuickActionKind.GitCommand, Payload: "git status --short --branch", ExecuteLabel: "Run", - IsPrimary: true); - var invocations = new List<(string WorkingDirectory, string Script)>(); - var service = new RepositoryGitQuickActionExecutionService((workingDirectory, script, _) => { - invocations.Add((workingDirectory, script)); - return Task.FromResult(new PowerShellExecutionResult( + IsPrimary: true, + GitOperation: RepositoryGitOperationKind.StatusShortBranch); + var invocations = new List(); + var service = new RepositoryGitQuickActionExecutionService((request, _) => { + invocations.Add(request); + return Task.FromResult(new GitCommandResult( + GitCommandKind.StatusShortBranch, + request.WorkingDirectory, + "git status --short --branch", 0, - TimeSpan.Zero, "## codex/release-ops-studio-foundation", - string.Empty)); + string.Empty, + "git", + TimeSpan.Zero, + timedOut: false)); }); var result = await service.ExecuteAsync(@"C:\Support\GitHub\PSPublishModule", action); @@ -92,7 +101,7 @@ public async Task ExecuteAsync_UsesSharedPowerShellRunnerContract() Assert.True(result.Succeeded); Assert.Single(invocations); Assert.Equal(@"C:\Support\GitHub\PSPublishModule", invocations[0].WorkingDirectory); - Assert.Equal("git status --short --branch", invocations[0].Script); + Assert.Equal(GitCommandKind.StatusShortBranch, invocations[0].CommandKind); Assert.Contains("completed successfully", result.Summary); } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioRepositoryPlanPreviewServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioRepositoryPlanPreviewServiceTests.cs index 2d9b442f..9dc69bc7 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioRepositoryPlanPreviewServiceTests.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioRepositoryPlanPreviewServiceTests.cs @@ -1,3 +1,6 @@ +using PowerForge; +using PowerForgeStudio.Domain.Catalog; +using PowerForgeStudio.Domain.Portfolio; using PowerForgeStudio.Orchestrator.Portfolio; namespace PowerForgeStudio.Tests; @@ -39,6 +42,58 @@ public void ResolveProjectConfigPath_FallsBackToRootBuildConfigWhenSiblingMissin Assert.Equal(rootConfigPath, resolved); } + [Fact] + public async Task PopulatePlanPreviewAsync_UsesSharedProjectBuildHostServiceForProjectPlans() + { + using var scope = new TemporaryDirectoryScope(); + var repositoryRoot = scope.CreateDirectory("LibraryRepo"); + var buildDirectory = scope.CreateDirectory(Path.Combine("LibraryRepo", "Build")); + var buildScriptPath = Path.Combine(buildDirectory, "Build-Project.ps1"); + var configPath = Path.Combine(buildDirectory, "project.build.json"); + + File.WriteAllText(buildScriptPath, "# test"); + File.WriteAllText( + configPath, + """ + { + "RootPath": ".", + "Build": true + } + """); + + var projectBuildHostService = new ProjectBuildHostService( + new NullLogger(), + executeRelease: spec => new DotNetRepositoryReleaseResult { Success = true, ResolvedVersion = "1.0.0" }, + publishGitHub: null, + validateGitHubPreflight: null); + var service = new RepositoryPlanPreviewService( + projectBuildHostService, + new ProjectBuildCommandHostService(new ThrowingPowerShellRunner()), + new ModuleBuildHostService(new ThrowingPowerShellRunner())); + + var item = new RepositoryPortfolioItem( + new RepositoryCatalogEntry( + Name: "LibraryRepo", + RootPath: repositoryRoot, + RepositoryKind: ReleaseRepositoryKind.Library, + WorkspaceKind: ReleaseWorkspaceKind.PrimaryRepository, + ModuleBuildScriptPath: null, + ProjectBuildScriptPath: buildScriptPath, + IsWorktree: false, + HasWebsiteSignals: false), + new RepositoryGitSnapshot(true, "main", "origin/main", 0, 0, 0, 0), + new RepositoryReadiness(RepositoryReadinessKind.Ready, "Ready")); + + var result = await service.PopulatePlanPreviewAsync([item], new PlanPreviewOptions { MaxRepositories = 1 }); + + var updated = Assert.Single(result); + var plan = Assert.Single(updated.PlanResults!); + Assert.Equal(RepositoryPlanAdapterKind.ProjectPlan, plan.AdapterKind); + Assert.Equal(RepositoryPlanStatus.Succeeded, plan.Status); + Assert.NotNull(plan.PlanPath); + Assert.True(File.Exists(plan.PlanPath!)); + } + private sealed class TemporaryDirectoryScope : IDisposable { public TemporaryDirectoryScope() @@ -64,4 +119,10 @@ public void Dispose() } } } + + private sealed class ThrowingPowerShellRunner : IPowerShellRunner + { + public PowerShellRunResult Run(PowerShellRunRequest request) + => throw new InvalidOperationException("PowerShell should not be used for project plan preview when shared host service is available."); + } } diff --git a/PowerForgeStudio.Tests/PowerForgeStudioStateDatabaseTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioStateDatabaseTests.cs index 4279de59..3bd38c7e 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioStateDatabaseTests.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioStateDatabaseTests.cs @@ -2,6 +2,8 @@ using PowerForgeStudio.Domain.Portfolio; using PowerForgeStudio.Domain.Publish; using PowerForgeStudio.Domain.Queue; +using PowerForgeStudio.Domain.Signing; +using PowerForgeStudio.Domain.Verification; using PowerForgeStudio.Orchestrator.Storage; namespace PowerForgeStudio.Tests; @@ -11,11 +13,7 @@ public sealed class PowerForgeStudioStateDatabaseTests [Fact] public async Task PersistPortfolioSnapshotAsync_RoundTripsGitHubAndDriftSignals() { - var testDirectory = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests"); - Directory.CreateDirectory(testDirectory); - var databasePath = Path.Combine(testDirectory, $"{Guid.NewGuid():N}.db"); - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(); + var stateDatabase = await CreateStateDatabaseAsync(); var portfolio = new[] { new RepositoryPortfolioItem( @@ -78,11 +76,7 @@ public async Task PersistPortfolioSnapshotAsync_RoundTripsGitHubAndDriftSignals( [Fact] public async Task PersistPortfolioViewStateAsync_RoundTripsFocusAndSearch() { - var testDirectory = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests"); - Directory.CreateDirectory(testDirectory); - var databasePath = Path.Combine(testDirectory, $"{Guid.NewGuid():N}.db"); - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(); + var stateDatabase = await CreateStateDatabaseAsync(); var viewState = new RepositoryPortfolioViewState( PresetKey: "ready-today", @@ -104,11 +98,7 @@ public async Task PersistPortfolioViewStateAsync_RoundTripsFocusAndSearch() [Fact] public async Task PersistQueueSessionAsync_RoundTripsScopeMetadata() { - var testDirectory = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests"); - Directory.CreateDirectory(testDirectory); - var databasePath = Path.Combine(testDirectory, $"{Guid.NewGuid():N}.db"); - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(); + var stateDatabase = await CreateStateDatabaseAsync(); var session = new ReleaseQueueSession( SessionId: Guid.NewGuid().ToString("N"), @@ -144,11 +134,7 @@ public async Task PersistQueueSessionAsync_RoundTripsScopeMetadata() [Fact] public async Task PersistPublishReceiptsAsync_RoundTripsSourcePath() { - var testDirectory = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests"); - Directory.CreateDirectory(testDirectory); - var databasePath = Path.Combine(testDirectory, $"{Guid.NewGuid():N}.db"); - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(); + var stateDatabase = await CreateStateDatabaseAsync(); await stateDatabase.PersistPublishReceiptsAsync( "session-1", @@ -173,14 +159,65 @@ await stateDatabase.PersistPublishReceiptsAsync( Assert.Equal("https://api.nuget.org/v3/index.json", receipt.Destination); } + [Fact] + public async Task PersistSigningReceiptsAsync_RoundTripsArtifactMetadata() + { + var stateDatabase = await CreateStateDatabaseAsync(); + + await stateDatabase.PersistSigningReceiptsAsync( + "session-1", + [ + new ReleaseSigningReceipt( + RootPath: @"C:\Support\GitHub\DbaClientX", + RepositoryName: "DbaClientX", + AdapterKind: "ModuleBuild", + ArtifactPath: @"C:\Temp\DbaClientX\DbaClientX.psd1", + ArtifactKind: "ModuleManifest", + Status: ReleaseSigningReceiptStatus.Signed, + Summary: "Signed manifest.", + SignedAtUtc: DateTimeOffset.UtcNow) + ]); + + var receipts = await stateDatabase.LoadSigningReceiptsAsync("session-1"); + + var receipt = Assert.Single(receipts); + Assert.Equal(@"C:\Temp\DbaClientX\DbaClientX.psd1", receipt.ArtifactPath); + Assert.Equal("ModuleManifest", receipt.ArtifactKind); + Assert.Equal(ReleaseSigningReceiptStatus.Signed, receipt.Status); + } + + [Fact] + public async Task PersistVerificationReceiptsAsync_RoundTripsDestinationAndStatus() + { + var stateDatabase = await CreateStateDatabaseAsync(); + + await stateDatabase.PersistVerificationReceiptsAsync( + "session-1", + [ + new ReleaseVerificationReceipt( + RootPath: @"C:\Support\GitHub\DbaClientX", + RepositoryName: "DbaClientX", + AdapterKind: "ModulePublish", + TargetName: "PSGallery", + TargetKind: "PowerShellRepository", + Destination: "https://www.powershellgallery.com/api/v2", + Status: ReleaseVerificationReceiptStatus.Verified, + Summary: "Verified published module.", + VerifiedAtUtc: DateTimeOffset.UtcNow) + ]); + + var receipts = await stateDatabase.LoadVerificationReceiptsAsync("session-1"); + + var receipt = Assert.Single(receipts); + Assert.Equal("https://www.powershellgallery.com/api/v2", receipt.Destination); + Assert.Equal(ReleaseVerificationReceiptStatus.Verified, receipt.Status); + Assert.Equal("PSGallery", receipt.TargetName); + } + [Fact] public async Task PersistGitQuickActionReceiptAsync_RoundTripsLatestAction() { - var testDirectory = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests"); - Directory.CreateDirectory(testDirectory); - var databasePath = Path.Combine(testDirectory, $"{Guid.NewGuid():N}.db"); - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(); + var stateDatabase = await CreateStateDatabaseAsync(); await stateDatabase.PersistGitQuickActionReceiptAsync( new RepositoryGitQuickActionReceipt( @@ -205,11 +242,7 @@ await stateDatabase.PersistGitQuickActionReceiptAsync( [Fact] public async Task PersistPortfolioSnapshotAsync_RollsBackWhenSnapshotWriteFails() { - var testDirectory = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests"); - Directory.CreateDirectory(testDirectory); - var databasePath = Path.Combine(testDirectory, $"{Guid.NewGuid():N}.db"); - var stateDatabase = new ReleaseStateDatabase(databasePath); - await stateDatabase.InitializeAsync(); + var stateDatabase = await CreateStateDatabaseAsync(); var originalPortfolio = new[] { CreatePortfolioItem( @@ -237,6 +270,72 @@ public async Task PersistPortfolioSnapshotAsync_RollsBackWhenSnapshotWriteFails( Assert.Equal("Original snapshot.", snapshot.ReadinessReason); } + [Fact] + public async Task PersistPlanSnapshotsAsync_ReplacesExistingRows() + { + var stateDatabase = await CreateStateDatabaseAsync(); + + var originalPortfolio = new[] { + CreatePortfolioItem( + rootPath: @"C:\Support\GitHub\DbaClientX", + repositoryName: "DbaClientX", + readinessKind: RepositoryReadinessKind.Ready, + readinessReason: "Original snapshot.") with { + PlanResults = [ + new RepositoryPlanResult( + RepositoryPlanAdapterKind.ProjectPlan, + RepositoryPlanStatus.Succeeded, + "Original plan.", + PlanPath: @"C:\Temp\original.plan.json", + ExitCode: 0, + DurationSeconds: 1.0) + ] + } + }; + + var replacementPortfolio = new[] { + CreatePortfolioItem( + rootPath: @"C:\Support\GitHub\PSPublishModule", + repositoryName: "PSPublishModule", + readinessKind: RepositoryReadinessKind.Attention, + readinessReason: "Replacement snapshot.") with { + PlanResults = [ + new RepositoryPlanResult( + RepositoryPlanAdapterKind.ModuleJsonExport, + RepositoryPlanStatus.Failed, + "Replacement plan.", + PlanPath: @"C:\Temp\replacement.plan.json", + ExitCode: 1, + DurationSeconds: 2.0) + ] + } + }; + + await stateDatabase.PersistPortfolioSnapshotAsync(originalPortfolio); + await stateDatabase.PersistPlanSnapshotsAsync(originalPortfolio); + await stateDatabase.PersistPortfolioSnapshotAsync(replacementPortfolio); + await stateDatabase.PersistPlanSnapshotsAsync(replacementPortfolio); + + var loaded = await stateDatabase.LoadPortfolioSnapshotAsync(); + + var reloaded = Assert.Single(loaded); + Assert.Equal("PSPublishModule", reloaded.Name); + var plan = Assert.Single(reloaded.PlanResults!); + Assert.Equal(RepositoryPlanAdapterKind.ModuleJsonExport, plan.AdapterKind); + Assert.Equal("Replacement plan.", plan.Summary); + Assert.Equal(@"C:\Temp\replacement.plan.json", plan.PlanPath); + } + + private static async Task CreateStateDatabaseAsync() + { + var testDirectory = Path.Combine(Path.GetTempPath(), "PowerForgeStudioTests"); + Directory.CreateDirectory(testDirectory); + var databasePath = Path.Combine(testDirectory, $"{Guid.NewGuid():N}.db"); + var stateDatabase = new ReleaseStateDatabase(databasePath); + await stateDatabase.InitializeAsync(); + return stateDatabase; + } + private static RepositoryPortfolioItem CreatePortfolioItem(string rootPath, string repositoryName, RepositoryReadinessKind readinessKind, string readinessReason) { return new RepositoryPortfolioItem( diff --git a/PowerForgeStudio.Tests/PowerForgeStudioVerificationExecutionServiceTests.cs b/PowerForgeStudio.Tests/PowerForgeStudioVerificationExecutionServiceTests.cs index 1d2c0ca4..b142f8ab 100644 --- a/PowerForgeStudio.Tests/PowerForgeStudioVerificationExecutionServiceTests.cs +++ b/PowerForgeStudio.Tests/PowerForgeStudioVerificationExecutionServiceTests.cs @@ -2,11 +2,11 @@ using System.IO.Compression; using System.Net; using System.Net.Http; +using PowerForge; using PowerForgeStudio.Domain.Catalog; using PowerForgeStudio.Domain.Publish; using PowerForgeStudio.Domain.Queue; using PowerForgeStudio.Domain.Verification; -using PowerForgeStudio.Orchestrator.Portfolio; using PowerForgeStudio.Orchestrator.Queue; namespace PowerForgeStudio.Tests; @@ -158,7 +158,7 @@ public async Task ExecuteAsync_CustomNuGetV3Feed_VerifiesPackageAgainstConfigure var queueItem = CreateVerifyReadyQueueItem(publishResult.RootPath, "Contoso.ReleaseOps", ReleaseRepositoryKind.Library, JsonSerializer.Serialize(publishResult)); using var client = new HttpClient(new StubHttpMessageHandler(request => CreateResponse(request.RequestUri))); - var service = new ReleaseVerificationExecutionService(client, (_, _, _) => Task.FromResult(new PowerShellExecutionResult(1, TimeSpan.Zero, string.Empty, string.Empty))); + var service = new ReleaseVerificationExecutionService(client, new PowerShellRepositoryResolver(new StubPowerShellRunner(_ => new PowerShellRunResult(1, string.Empty, string.Empty, "pwsh")))); var result = await service.ExecuteAsync(queueItem); @@ -193,30 +193,20 @@ public async Task ExecuteAsync_CustomPowerShellRepository_VerifiesModuleAgainstR var queueItem = CreateVerifyReadyQueueItem(publishResult.RootPath, "ContosoModule", ReleaseRepositoryKind.Module, JsonSerializer.Serialize(publishResult)); using var client = new HttpClient(new StubHttpMessageHandler(request => CreateResponse(request.RequestUri))); - Task PowerShellResolver(string _, string script, CancellationToken __) - { - if (script.Contains("Get-PSResourceRepository", StringComparison.Ordinal)) - { - return Task.FromResult(new PowerShellExecutionResult( - 0, - TimeSpan.Zero, - "{\"Name\":\"PrivateGallery\",\"SourceUri\":\"https://packages.contoso.test/powershell/v3/index.json\",\"PublishUri\":\"https://packages.contoso.test/powershell/api/v2/package\"}", - string.Empty)); - } - - if (script.Contains("Import-PowerShellDataFile", StringComparison.Ordinal)) - { - return Task.FromResult(new PowerShellExecutionResult( - 0, - TimeSpan.Zero, - "{\"ModuleName\":\"ContosoModule\",\"ModuleVersion\":\"2.5.0\",\"PreRelease\":\"preview1\"}", - string.Empty)); - } - - return Task.FromResult(new PowerShellExecutionResult(1, TimeSpan.Zero, string.Empty, "Unexpected script")); - } - - var service = new ReleaseVerificationExecutionService(client, PowerShellResolver); + var service = new ReleaseVerificationExecutionService( + client, + new PowerShellRepositoryResolver(new StubPowerShellRunner(request => { + if (request.CommandText is not null && request.CommandText.Contains("Get-PSResourceRepository", StringComparison.Ordinal)) + { + return new PowerShellRunResult( + 0, + "{\"Name\":\"PrivateGallery\",\"SourceUri\":\"https://packages.contoso.test/powershell/v3/index.json\",\"PublishUri\":\"https://packages.contoso.test/powershell/api/v2/package\"}", + string.Empty, + "pwsh"); + } + + return new PowerShellRunResult(1, string.Empty, "Unexpected script", "pwsh"); + }))); var result = await service.ExecuteAsync(queueItem); Assert.True(result.Succeeded); @@ -306,6 +296,19 @@ protected override Task SendAsync(HttpRequestMessage reques => Task.FromResult(responseFactory(request)); } + private sealed class StubPowerShellRunner : IPowerShellRunner + { + private readonly Func _execute; + + public StubPowerShellRunner(Func execute) + { + _execute = execute; + } + + public PowerShellRunResult Run(PowerShellRunRequest request) + => _execute(request); + } + private sealed class TemporaryPackageScope(string rootPath, string packagePath) : IDisposable { public string PackagePath { get; } = packagePath; diff --git a/README.MD b/README.MD index be3b897d..707176a3 100644 --- a/README.MD +++ b/README.MD @@ -143,6 +143,94 @@ powerforge github artifacts prune --name "test-results*,coverage*,github-pages" powerforge github artifacts prune --apply --keep 5 --max-age-days 7 --max-delete 200 ``` +GitHub cache cleanup, runner housekeeping, and config-driven orchestration: + +```powershell +# GitHub Actions cache cleanup (dry-run by default) +powerforge github caches prune --key "ubuntu-*,windows-*" --keep 1 --max-age-days 14 + +# Runner cleanup for hosted/self-hosted GitHub Actions runners +powerforge github runner cleanup --apply --min-free-gb 20 + +# Config-driven housekeeping (auto-loads .powerforge/github-housekeeping.json when present) +powerforge github housekeeping --apply +``` + +The recommended cross-repo setup is one config file plus one reusable workflow: + +```json +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/github.housekeeping.schema.json", + "repository": "EvotecIT/OfficeIMO", + "tokenEnvName": "GITHUB_TOKEN", + "artifacts": { + "enabled": true, + "keepLatestPerName": 10, + "maxAgeDays": 7 + }, + "caches": { + "enabled": true, + "keepLatestPerKey": 2, + "maxAgeDays": 14 + }, + "runner": { + "enabled": false + } +} +``` + +```yaml +permissions: + contents: read + actions: write + +jobs: + housekeeping: + uses: EvotecIT/PSPublishModule/.github/workflows/reusable-github-housekeeping.yml@main + with: + config-path: ./.powerforge/github-housekeeping.json + secrets: inherit +``` + +If you need direct action usage instead of the reusable workflow: + +```yaml +permissions: + contents: read + actions: write + +jobs: + housekeeping: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EvotecIT/PSPublishModule/.github/actions/github-housekeeping@main + with: + config-path: ./.powerforge/github-housekeeping.json + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +For release/build packaging, this repo now also ships a standard project-build entrypoint: + +```powershell +.\Build\Build-Project.ps1 +.\Build\Build-Project.ps1 -Plan +.\Build\Build-Project.ps1 -PublishNuget $true -PublishGitHub $true +``` + +For downloadable CLI binaries, use the tool release builders: + +```powershell +# Build PowerForge.exe / PowerForge for one runtime +.\Build\Build-PowerForge.ps1 -Tool PowerForge -Runtime win-x64 -Framework net8.0 -Flavor SingleFx -Zip + +# Build PowerForgeWeb.exe / PowerForgeWeb +.\Build\Build-PowerForgeWeb.ps1 -Runtime win-x64 -Framework net8.0 -Flavor SingleFx -Zip + +# 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 +``` + Introduced in **1.0.0** a new way to build PowerShell module based on DSL language. ```powershell diff --git a/Schemas/github.housekeeping.schema.json b/Schemas/github.housekeeping.schema.json new file mode 100644 index 00000000..a3ba0ff5 --- /dev/null +++ b/Schemas/github.housekeeping.schema.json @@ -0,0 +1,150 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GitHub Housekeeping Configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "ApiBaseUrl": { + "type": [ + "string", + "null" + ] + }, + "Repository": { + "type": [ + "string", + "null" + ] + }, + "Token": { + "type": [ + "string", + "null" + ] + }, + "TokenEnvName": { + "type": "string" + }, + "DryRun": { + "type": "boolean" + }, + "Artifacts": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { "type": "boolean" }, + "IncludeNames": { + "type": "array", + "items": { "type": "string" } + }, + "ExcludeNames": { + "type": "array", + "items": { "type": "string" } + }, + "KeepLatestPerName": { "type": "integer", "minimum": 0 }, + "MaxAgeDays": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "MaxDelete": { "type": "integer", "minimum": 1 }, + "PageSize": { "type": "integer", "minimum": 1, "maximum": 100 }, + "FailOnDeleteError": { "type": "boolean" } + } + }, + "Caches": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { "type": "boolean" }, + "IncludeKeys": { + "type": "array", + "items": { "type": "string" } + }, + "ExcludeKeys": { + "type": "array", + "items": { "type": "string" } + }, + "KeepLatestPerKey": { "type": "integer", "minimum": 0 }, + "MaxAgeDays": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "MaxDelete": { "type": "integer", "minimum": 1 }, + "PageSize": { "type": "integer", "minimum": 1, "maximum": 100 }, + "FailOnDeleteError": { "type": "boolean" } + } + }, + "Runner": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { "type": "boolean" }, + "RunnerTempPath": { + "type": [ + "string", + "null" + ] + }, + "WorkRootPath": { + "type": [ + "string", + "null" + ] + }, + "RunnerRootPath": { + "type": [ + "string", + "null" + ] + }, + "DiagnosticsRootPath": { + "type": [ + "string", + "null" + ] + }, + "ToolCachePath": { + "type": [ + "string", + "null" + ] + }, + "MinFreeGb": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "AggressiveThresholdGb": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "DiagnosticsRetentionDays": { "type": "integer", "minimum": 0 }, + "ActionsRetentionDays": { "type": "integer", "minimum": 0 }, + "ToolCacheRetentionDays": { "type": "integer", "minimum": 0 }, + "Aggressive": { "type": "boolean" }, + "CleanDiagnostics": { "type": "boolean" }, + "CleanRunnerTemp": { "type": "boolean" }, + "CleanActionsCache": { "type": "boolean" }, + "CleanToolCache": { "type": "boolean" }, + "ClearDotNetCaches": { "type": "boolean" }, + "PruneDocker": { "type": "boolean" }, + "IncludeDockerVolumes": { "type": "boolean" }, + "AllowSudo": { "type": "boolean" } + } + } + } +}