From 327a0f5f7188bc7ce47d0594fc507c0a3b4ae0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 08:24:18 +0100 Subject: [PATCH 01/13] Fix reusable GitHub housekeeping action --- .../Invoke-PowerForgeHousekeeping.ps1 | 10 +++--- .github/actions/github-housekeeping/README.md | 29 ++++++++++++++++ .../actions/github-housekeeping/action.yml | 2 +- .../GitHubHousekeepingActionTests.cs | 33 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 PowerForge.Tests/GitHubHousekeepingActionTests.cs diff --git a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 index 5a6ace24..0727d71f 100644 --- a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 +++ b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 @@ -3,7 +3,7 @@ param() $ErrorActionPreference = 'Stop' -$repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path +$repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../../..")).Path $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" function Format-GiB { @@ -109,11 +109,13 @@ if (-not (Test-Path -LiteralPath $configPath)) { } $arguments = [System.Collections.Generic.List[string]]::new() -$arguments.AddRange(@( - 'run', '--project', $project, '-c', 'Release', '--no-build', '--', +foreach ($argument in @( + 'run', '--project', $project, '--framework', 'net10.0', '-c', 'Release', '--no-build', '--', 'github', 'housekeeping', '--config', $configPath -)) +)) { + $null = $arguments.Add([string]$argument) +} if ($env:INPUT_APPLY -eq 'true') { $null = $arguments.Add('--apply') diff --git a/.github/actions/github-housekeeping/README.md b/.github/actions/github-housekeeping/README.md index fb82c154..3d9532b5 100644 --- a/.github/actions/github-housekeeping/README.md +++ b/.github/actions/github-housekeeping/README.md @@ -22,9 +22,38 @@ jobs: uses: EvotecIT/PSPublishModule/.github/workflows/reusable-github-housekeeping.yml@main with: config-path: ./.powerforge/github-housekeeping.json + powerforge-ref: main secrets: inherit ``` +For immutable pinning, use the same PSPublishModule commit SHA for both the reusable workflow ref and `powerforge-ref`. + +Minimal config: + +```json +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/github.housekeeping.schema.json", + "repository": "EvotecIT/YourRepo", + "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 + } +} +``` + ## Direct action usage ```yaml diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml index 5ba2daae..3fe1891b 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -21,7 +21,7 @@ runs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - global-json-file: ${{ github.action_path }}/../../global.json + global-json-file: ${{ github.action_path }}/../../../global.json - name: Build PowerForge CLI shell: pwsh diff --git a/PowerForge.Tests/GitHubHousekeepingActionTests.cs b/PowerForge.Tests/GitHubHousekeepingActionTests.cs new file mode 100644 index 00000000..487ec8a8 --- /dev/null +++ b/PowerForge.Tests/GitHubHousekeepingActionTests.cs @@ -0,0 +1,33 @@ +namespace PowerForge.Tests; + +public sealed class GitHubHousekeepingActionTests +{ + [Fact] + public void CompositeAction_AssetPaths_ShouldResolveFromActionDirectory() + { + var repoRoot = FindRepoRoot(); + var actionRoot = Path.Combine(repoRoot, ".github", "actions", "github-housekeeping"); + + Assert.True(Directory.Exists(actionRoot), $"Action directory not found: {actionRoot}"); + + var globalJsonPath = Path.GetFullPath(Path.Combine(actionRoot, "..", "..", "..", "global.json")); + var cliProjectPath = Path.GetFullPath(Path.Combine(actionRoot, "..", "..", "..", "PowerForge.Cli", "PowerForge.Cli.csproj")); + + Assert.True(File.Exists(globalJsonPath), $"global.json should resolve from composite action directory: {globalJsonPath}"); + Assert.True(File.Exists(cliProjectPath), $"PowerForge.Cli project should resolve from composite action directory: {cliProjectPath}"); + } + + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var i = 0; i < 12 && current is not null; i++) + { + var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); + if (File.Exists(marker)) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("Unable to locate repository root for GitHub housekeeping action tests."); + } +} From 2831df4e3edbe8c9bee6bef3e7ee3a0b71a5e2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 08:28:30 +0100 Subject: [PATCH 02/13] Fix housekeeping action build step path --- .github/actions/github-housekeeping/action.yml | 2 +- PowerForge.Tests/GitHubHousekeepingActionTests.cs | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml index 3fe1891b..281fc051 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -26,7 +26,7 @@ runs: - name: Build PowerForge CLI shell: pwsh run: | - $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path + $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: diff --git a/PowerForge.Tests/GitHubHousekeepingActionTests.cs b/PowerForge.Tests/GitHubHousekeepingActionTests.cs index 487ec8a8..f0d33524 100644 --- a/PowerForge.Tests/GitHubHousekeepingActionTests.cs +++ b/PowerForge.Tests/GitHubHousekeepingActionTests.cs @@ -7,14 +7,25 @@ public void CompositeAction_AssetPaths_ShouldResolveFromActionDirectory() { var repoRoot = FindRepoRoot(); var actionRoot = Path.Combine(repoRoot, ".github", "actions", "github-housekeeping"); + var actionYamlPath = Path.Combine(actionRoot, "action.yml"); + var scriptPath = Path.Combine(actionRoot, "Invoke-PowerForgeHousekeeping.ps1"); Assert.True(Directory.Exists(actionRoot), $"Action directory not found: {actionRoot}"); + Assert.True(File.Exists(actionYamlPath), $"Composite action definition not found: {actionYamlPath}"); + Assert.True(File.Exists(scriptPath), $"Composite action script not found: {scriptPath}"); var globalJsonPath = Path.GetFullPath(Path.Combine(actionRoot, "..", "..", "..", "global.json")); var cliProjectPath = Path.GetFullPath(Path.Combine(actionRoot, "..", "..", "..", "PowerForge.Cli", "PowerForge.Cli.csproj")); Assert.True(File.Exists(globalJsonPath), $"global.json should resolve from composite action directory: {globalJsonPath}"); Assert.True(File.Exists(cliProjectPath), $"PowerForge.Cli project should resolve from composite action directory: {cliProjectPath}"); + + var actionYaml = File.ReadAllText(actionYamlPath); + var script = File.ReadAllText(scriptPath); + + Assert.Contains("../../../global.json", actionYaml, StringComparison.Ordinal); + Assert.Contains("../../..", actionYaml, StringComparison.Ordinal); + Assert.Contains("../../..", script, StringComparison.Ordinal); } private static string FindRepoRoot() From 62a72786d9c2acc02f843643b624b0b86a731cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 08:47:45 +0100 Subject: [PATCH 03/13] Improve GitHub housekeeping reporting --- .../Invoke-PowerForgeHousekeeping.ps1 | 329 +++++++++++++++--- .github/actions/github-housekeeping/README.md | 11 +- .../actions/github-housekeeping/action.yml | 19 + .github/workflows/github-housekeeping.yml | 2 +- .../powerforge-github-housekeeping.yml | 76 ++++ .../reusable-github-housekeeping.yml | 45 ++- .../GitHubHousekeepingActionTests.cs | 9 + 7 files changed, 422 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/powerforge-github-housekeeping.yml diff --git a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 index 0727d71f..c9e6278d 100644 --- a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 +++ b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 @@ -16,6 +16,46 @@ function Format-GiB { return ('{0:N1} GiB' -f ($Bytes / 1GB)) } +function Format-NullableGiB { + param($Bytes) + + if ($null -eq $Bytes) { + return '-' + } + + return Format-GiB ([long]$Bytes) +} + +function Format-NullableCount { + param($Value) + + if ($null -eq $Value) { + return '-' + } + + return [string]$Value +} + +function Format-NullableDate { + param($Value) + + if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { + return '-' + } + + return [string]$Value +} + +function Escape-MarkdownCell { + param([string] $Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return '-' + } + + return $Value.Replace('|', '\|').Replace("`r", ' ').Replace("`n", '
') +} + function Write-MarkdownSummary { param([string[]] $Lines) @@ -26,84 +66,281 @@ function Write-MarkdownSummary { 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' +function Write-GitHubOutput { + param( + [string] $Name, + [string] $Value + ) + + if ([string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) { + return } - if ([System.IO.Path]::IsPathRooted($configPath)) { - return [System.IO.Path]::GetFullPath($configPath) + "{0}={1}" -f $Name, $Value | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append +} + +function Resolve-WorkspacePath { + param( + [string] $ConfiguredPath, + [string] $DefaultRelativePath + ) + + $path = $ConfiguredPath + if ([string]::IsNullOrWhiteSpace($path)) { + $path = $DefaultRelativePath + } + + if ([System.IO.Path]::IsPathRooted($path)) { + return [System.IO.Path]::GetFullPath($path) } if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { throw 'GITHUB_WORKSPACE is not set.' } - return [System.IO.Path]::GetFullPath((Join-Path $env:GITHUB_WORKSPACE $configPath)) + return [System.IO.Path]::GetFullPath((Join-Path $env:GITHUB_WORKSPACE $path)) } -function Write-HousekeepingSummary { - param([pscustomobject] $Envelope) +function Resolve-ConfigPath { + return Resolve-WorkspacePath -ConfiguredPath $env:INPUT_CONFIG_PATH -DefaultRelativePath '.powerforge/github-housekeeping.json' +} - if (-not $Envelope.result) { +function Add-SectionTable { + param( + [System.Collections.Generic.List[string]] $Lines, + [object[]] $Rows + ) + + if ($Rows.Count -eq 0) { 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' })" + $Lines.Add('') + $Lines.Add('## Storage Summary') + $Lines.Add('') + $Lines.Add('| Section | Status | Planned | Deleted | Failed | Before | After |') + $Lines.Add('| --- | --- | ---: | ---: | ---: | --- | --- |') + + foreach ($row in $Rows) { + $Lines.Add("| $($row.Section) | $($row.Status) | $($row.Planned) | $($row.Deleted) | $($row.Failed) | $($row.Before) | $($row.After) |") + } +} + +function Add-ItemDetails { + param( + [System.Collections.Generic.List[string]] $Lines, + [string] $Title, + [object[]] $Items, + [string] $Type ) - if ($result.message) { - $lines += "- Message: $($result.message)" + if ($Items.Count -eq 0) { + return } - 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))" + $Lines.Add('') + $Lines.Add("
") + $Lines.Add("$Title ($($Items.Count))") + $Lines.Add('') + + if ($Type -eq 'artifacts') { + $Lines.Add('| Name | Size | Created | Updated | Reason | Delete status |') + $Lines.Add('| --- | ---: | --- | --- | --- | --- |') + foreach ($item in $Items | Select-Object -First 20) { + $deleteState = if ($null -ne $item.deleteError -and -not [string]::IsNullOrWhiteSpace([string]$item.deleteError)) { + "failed ($([string]$item.deleteStatusCode))" + } elseif ($null -ne $item.deleteStatusCode) { + "deleted ($([string]$item.deleteStatusCode))" + } else { + 'planned' + } + + $Lines.Add("| $(Escape-MarkdownCell ([string]$item.name)) | $(Format-GiB ([long]$item.sizeInBytes)) | $(Format-NullableDate $item.createdAt) | $(Format-NullableDate $item.updatedAt) | $(Escape-MarkdownCell ([string]$item.reason)) | $(Escape-MarkdownCell $deleteState) |") } - if ($result.caches.usageAfter) { - $lines += "- Usage after: $($result.caches.usageAfter.activeCachesCount) caches, $(Format-GiB ([long]$result.caches.usageAfter.activeCachesSizeInBytes))" + } elseif ($Type -eq 'caches') { + $Lines.Add('| Key | Size | Created | Last accessed | Reason | Delete status |') + $Lines.Add('| --- | ---: | --- | --- | --- | --- |') + foreach ($item in $Items | Select-Object -First 20) { + $deleteState = if ($null -ne $item.deleteError -and -not [string]::IsNullOrWhiteSpace([string]$item.deleteError)) { + "failed ($([string]$item.deleteStatusCode))" + } elseif ($null -ne $item.deleteStatusCode) { + "deleted ($([string]$item.deleteStatusCode))" + } else { + 'planned' + } + + $Lines.Add("| $(Escape-MarkdownCell ([string]$item.key)) | $(Format-GiB ([long]$item.sizeInBytes)) | $(Format-NullableDate $item.createdAt) | $(Format-NullableDate $item.lastAccessedAt) | $(Escape-MarkdownCell ([string]$item.reason)) | $(Escape-MarkdownCell $deleteState) |") } - $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 ($Items.Count -gt 20) { + $Lines.Add('') + $Lines.Add('_Showing first 20 items._') + } + + $Lines.Add('') + $Lines.Add('
') +} + +function New-HousekeepingSummaryLines { + param([pscustomobject] $Envelope) + + if (-not $Envelope.result) { + return @( + '# PowerForge GitHub Housekeeping Report', + '', + '> ❌ **Housekeeping failed before section results were produced**', + '', + '| Field | Value |', + '| --- | --- |', + "| Success | $(if ($Envelope.success) { 'Yes' } else { 'No' }) |", + "| Exit code | $(Format-NullableCount $Envelope.exitCode) |", + "| Error | $(Escape-MarkdownCell ([string]$Envelope.error)) |" + ) + } + + $result = $Envelope.result + $repository = if ([string]::IsNullOrWhiteSpace([string]$result.repository)) { '(runner-only)' } else { [string]$result.repository } + $statusIcon = if ($Envelope.success) { '✅' } else { '❌' } + $mode = if ($result.dryRun) { 'dry-run' } else { 'apply' } + $rows = [System.Collections.Generic.List[object]]::new() + 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)" + $artifactStatus = if ($null -eq $result.artifacts.failedDeletes -or [int]$result.artifacts.failedDeletes -eq 0) { 'ok' } else { 'warnings' } + $rows.Add([pscustomobject]@{ + Section = 'Artifacts' + Status = $artifactStatus + Planned = ("{0} ({1})" -f (Format-NullableCount $result.artifacts.plannedDeletes), (Format-NullableGiB $result.artifacts.plannedDeleteBytes)) + Deleted = ("{0} ({1})" -f (Format-NullableCount $result.artifacts.deletedArtifacts), (Format-NullableGiB $result.artifacts.deletedBytes)) + Failed = Format-NullableCount $result.artifacts.failedDeletes + Before = '-' + After = '-' + }) + } + + if ($result.caches) { + $cacheStatus = if ($null -eq $result.caches.failedDeletes -or [int]$result.caches.failedDeletes -eq 0) { 'ok' } else { 'warnings' } + $cacheBefore = if ($result.caches.usageBefore) { + ("{0} caches / {1}" -f (Format-NullableCount $result.caches.usageBefore.activeCachesCount), (Format-NullableGiB $result.caches.usageBefore.activeCachesSizeInBytes)) + } else { + '-' + } + $cacheAfter = if ($result.caches.usageAfter) { + ("{0} caches / {1}" -f (Format-NullableCount $result.caches.usageAfter.activeCachesCount), (Format-NullableGiB $result.caches.usageAfter.activeCachesSizeInBytes)) + } else { + '-' + } + $rows.Add([pscustomobject]@{ + Section = 'Caches' + Status = $cacheStatus + Planned = ("{0} ({1})" -f (Format-NullableCount $result.caches.plannedDeletes), (Format-NullableGiB $result.caches.plannedDeleteBytes)) + Deleted = ("{0} ({1})" -f (Format-NullableCount $result.caches.deletedCaches), (Format-NullableGiB $result.caches.deletedBytes)) + Failed = Format-NullableCount $result.caches.failedDeletes + Before = $cacheBefore + After = $cacheAfter + }) } 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' })" + $rows.Add([pscustomobject]@{ + Section = 'Runner' + Status = if ($result.runner.success) { 'ok' } else { 'warnings' } + Planned = '-' + Deleted = '-' + Failed = if ($result.runner.success) { '0' } else { '1' } + Before = Format-NullableGiB $result.runner.freeBytesBefore + After = Format-NullableGiB $result.runner.freeBytesAfter + }) } - Write-Host ("GitHub housekeeping: requested={0}; completed={1}; failed={2}" -f ` - (@($result.requestedSections) -join ','), ` - (@($result.completedSections) -join ','), ` - (@($result.failedSections) -join ',')) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('# PowerForge GitHub Housekeeping Report') + $lines.Add('') + $lines.Add("> $statusIcon **$repository** ran in **$mode** mode") + $lines.Add('') + $lines.Add('| Field | Value |') + $lines.Add('| --- | --- |') + $lines.Add("| Success | $(if ($Envelope.success) { 'Yes' } else { 'No' }) |") + $lines.Add("| Requested sections | $(Escape-MarkdownCell ((@($result.requestedSections) -join ', '))) |") + $lines.Add("| Completed sections | $(Escape-MarkdownCell ((@($result.completedSections) -join ', '))) |") + $lines.Add("| Failed sections | $(Escape-MarkdownCell ((@($result.failedSections) -join ', '))) |") + + if ($result.message) { + $lines.Add("| Message | $(Escape-MarkdownCell ([string]$result.message)) |") + } + + Add-SectionTable -Lines $lines -Rows $rows.ToArray() + + if ($result.artifacts -and $result.artifacts.items) { + Add-ItemDetails -Lines $lines -Title 'Artifact selection details' -Items @($result.artifacts.items) -Type 'artifacts' + } + + if ($result.caches -and $result.caches.items) { + Add-ItemDetails -Lines $lines -Title 'Cache selection details' -Items @($result.caches.items) -Type 'caches' + } + + return $lines.ToArray() +} + +function Write-TextFile { + param( + [string] $Path, + [string[]] $Lines + ) + + $directory = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + Set-Content -LiteralPath $Path -Value ($Lines -join [Environment]::NewLine) -Encoding utf8 +} + +function Write-JsonFile { + param( + [string] $Path, + [string] $RawJson + ) + + $directory = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + Set-Content -LiteralPath $Path -Value $RawJson -Encoding utf8 +} + +function Write-HousekeepingSummary { + param( + [pscustomobject] $Envelope, + [string] $ReportPath, + [string] $SummaryPath, + [string] $RawJson + ) + + $lines = New-HousekeepingSummaryLines -Envelope $Envelope + + if ($Envelope.result) { + $result = $Envelope.result + Write-Host ("GitHub housekeeping: requested={0}; completed={1}; failed={2}" -f ` + (@($result.requestedSections) -join ','), ` + (@($result.completedSections) -join ','), ` + (@($result.failedSections) -join ',')) + } else { + Write-Host ("GitHub housekeeping failed before a detailed result was produced: {0}" -f ([string]$Envelope.error)) + } Write-MarkdownSummary -Lines ($lines + '') + Write-TextFile -Path $SummaryPath -Lines ($lines + '') + Write-JsonFile -Path $ReportPath -RawJson $RawJson + Write-GitHubOutput -Name 'report-path' -Value $ReportPath + Write-GitHubOutput -Name 'summary-path' -Value $SummaryPath } $configPath = Resolve-ConfigPath +$reportPath = Resolve-WorkspacePath -ConfiguredPath $env:INPUT_REPORT_PATH -DefaultRelativePath '.powerforge/_reports/github-housekeeping.json' +$summaryPath = Resolve-WorkspacePath -ConfiguredPath $env:INPUT_SUMMARY_PATH -DefaultRelativePath '.powerforge/_reports/github-housekeeping.md' + if (-not (Test-Path -LiteralPath $configPath)) { throw "Housekeeping config not found: $configPath" } @@ -149,7 +386,7 @@ try { throw } -Write-HousekeepingSummary -Envelope $envelope +Write-HousekeepingSummary -Envelope $envelope -ReportPath $reportPath -SummaryPath $summaryPath -RawJson $rawOutput if (-not $envelope.success) { Write-Host $rawOutput diff --git a/.github/actions/github-housekeeping/README.md b/.github/actions/github-housekeeping/README.md index 3d9532b5..2bf3a937 100644 --- a/.github/actions/github-housekeeping/README.md +++ b/.github/actions/github-housekeeping/README.md @@ -6,11 +6,12 @@ Reusable composite action that runs the config-driven `powerforge github houseke - 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 +- Writes a rich workflow summary with requested sections, storage deltas, and item details +- Persists a machine-readable JSON report plus a Markdown report artifact for later review ## Recommended usage -Use the reusable workflow for the leanest repo wiring: +Use the public reusable workflow for the leanest repo wiring: ```yaml permissions: @@ -19,7 +20,7 @@ permissions: jobs: housekeeping: - uses: EvotecIT/PSPublishModule/.github/workflows/reusable-github-housekeeping.yml@main + uses: EvotecIT/PSPublishModule/.github/workflows/powerforge-github-housekeeping.yml@main with: config-path: ./.powerforge/github-housekeeping.json powerforge-ref: main @@ -28,6 +29,8 @@ jobs: For immutable pinning, use the same PSPublishModule commit SHA for both the reusable workflow ref and `powerforge-ref`. +The reusable workflow uploads the generated JSON and Markdown reports as an artifact by default. + Minimal config: ```json @@ -77,3 +80,5 @@ jobs: - 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. +- The legacy workflow file `reusable-github-housekeeping.yml` remains as a compatibility alias, but `powerforge-github-housekeeping.yml` is the recommended public entrypoint. +- The composite action exposes `report-path` and `summary-path` outputs for callers that want to publish the generated reports elsewhere. diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml index 281fc051..0e8bed7e 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -14,6 +14,22 @@ inputs: description: Optional token override for remote GitHub cleanup. required: false default: "" + report-path: + description: Optional JSON report output path relative to the workspace. + required: false + default: ".powerforge/_reports/github-housekeeping.json" + summary-path: + description: Optional Markdown summary output path relative to the workspace. + required: false + default: ".powerforge/_reports/github-housekeeping.md" + +outputs: + report-path: + description: Resolved JSON report output path. + value: ${{ steps.housekeeping.outputs.report-path }} + summary-path: + description: Resolved Markdown summary output path. + value: ${{ steps.housekeeping.outputs.summary-path }} runs: using: composite @@ -34,6 +50,7 @@ runs: DOTNET_CLI_TELEMETRY_OPTOUT: 1 - name: Run GitHub housekeeping + id: housekeeping shell: pwsh run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 env: @@ -41,4 +58,6 @@ runs: DOTNET_CLI_TELEMETRY_OPTOUT: 1 INPUT_CONFIG_PATH: ${{ inputs['config-path'] }} INPUT_APPLY: ${{ inputs.apply }} + INPUT_REPORT_PATH: ${{ inputs['report-path'] }} + INPUT_SUMMARY_PATH: ${{ inputs['summary-path'] }} 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 index 0f1b16be..f2e03a75 100644 --- a/.github/workflows/github-housekeeping.yml +++ b/.github/workflows/github-housekeeping.yml @@ -20,7 +20,7 @@ concurrency: jobs: housekeeping: - uses: ./.github/workflows/reusable-github-housekeeping.yml + uses: ./.github/workflows/powerforge-github-housekeeping.yml with: config-path: ./.powerforge/github-housekeeping.json apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply == 'true' || github.event_name != 'workflow_dispatch' }} diff --git a/.github/workflows/powerforge-github-housekeeping.yml b/.github/workflows/powerforge-github-housekeeping.yml new file mode 100644 index 00000000..84c9f10e --- /dev/null +++ b/.github/workflows/powerforge-github-housekeeping.yml @@ -0,0 +1,76 @@ +name: PowerForge 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 + report-path: + description: Optional JSON report path relative to the caller workspace. + required: false + default: ".powerforge/_reports/github-housekeeping.json" + type: string + summary-path: + description: Optional Markdown summary path relative to the caller workspace. + required: false + default: ".powerforge/_reports/github-housekeeping.md" + type: string + report-artifact-name: + description: Artifact name used when uploading housekeeping reports. + required: false + default: "powerforge-github-housekeeping-reports" + 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 + id: 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 }} + report-path: ${{ inputs['report-path'] }} + summary-path: ${{ inputs['summary-path'] }} + + - name: Upload housekeeping reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs['report-artifact-name'] }} + path: | + ${{ steps.housekeeping.outputs.report-path }} + ${{ steps.housekeeping.outputs.summary-path }} + if-no-files-found: ignore diff --git a/.github/workflows/reusable-github-housekeeping.yml b/.github/workflows/reusable-github-housekeeping.yml index d23217c7..44ed9934 100644 --- a/.github/workflows/reusable-github-housekeeping.yml +++ b/.github/workflows/reusable-github-housekeeping.yml @@ -1,4 +1,4 @@ -name: Reusable GitHub Housekeeping +name: Reusable GitHub Housekeeping (Compatibility) on: workflow_call: @@ -18,6 +18,21 @@ on: required: false default: "main" type: string + report-path: + description: Optional JSON report path relative to the caller workspace. + required: false + default: ".powerforge/_reports/github-housekeeping.json" + type: string + summary-path: + description: Optional Markdown summary path relative to the caller workspace. + required: false + default: ".powerforge/_reports/github-housekeeping.md" + type: string + report-artifact-name: + description: Artifact name used when uploading housekeeping reports. + required: false + default: "powerforge-github-housekeeping-reports" + type: string secrets: github-token: required: false @@ -28,21 +43,13 @@ permissions: 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 }} + uses: ./.github/workflows/powerforge-github-housekeeping.yml + with: + config-path: ${{ inputs['config-path'] }} + apply: ${{ inputs.apply }} + powerforge-ref: ${{ inputs['powerforge-ref'] }} + report-path: ${{ inputs['report-path'] }} + summary-path: ${{ inputs['summary-path'] }} + report-artifact-name: ${{ inputs['report-artifact-name'] }} + secrets: + github-token: ${{ secrets['github-token'] }} diff --git a/PowerForge.Tests/GitHubHousekeepingActionTests.cs b/PowerForge.Tests/GitHubHousekeepingActionTests.cs index f0d33524..9afb75f2 100644 --- a/PowerForge.Tests/GitHubHousekeepingActionTests.cs +++ b/PowerForge.Tests/GitHubHousekeepingActionTests.cs @@ -9,10 +9,14 @@ public void CompositeAction_AssetPaths_ShouldResolveFromActionDirectory() var actionRoot = Path.Combine(repoRoot, ".github", "actions", "github-housekeeping"); var actionYamlPath = Path.Combine(actionRoot, "action.yml"); var scriptPath = Path.Combine(actionRoot, "Invoke-PowerForgeHousekeeping.ps1"); + var publicWorkflowPath = Path.Combine(repoRoot, ".github", "workflows", "powerforge-github-housekeeping.yml"); + var compatibilityWorkflowPath = Path.Combine(repoRoot, ".github", "workflows", "reusable-github-housekeeping.yml"); Assert.True(Directory.Exists(actionRoot), $"Action directory not found: {actionRoot}"); Assert.True(File.Exists(actionYamlPath), $"Composite action definition not found: {actionYamlPath}"); Assert.True(File.Exists(scriptPath), $"Composite action script not found: {scriptPath}"); + Assert.True(File.Exists(publicWorkflowPath), $"Public reusable workflow not found: {publicWorkflowPath}"); + Assert.True(File.Exists(compatibilityWorkflowPath), $"Compatibility reusable workflow not found: {compatibilityWorkflowPath}"); var globalJsonPath = Path.GetFullPath(Path.Combine(actionRoot, "..", "..", "..", "global.json")); var cliProjectPath = Path.GetFullPath(Path.Combine(actionRoot, "..", "..", "..", "PowerForge.Cli", "PowerForge.Cli.csproj")); @@ -22,10 +26,15 @@ public void CompositeAction_AssetPaths_ShouldResolveFromActionDirectory() var actionYaml = File.ReadAllText(actionYamlPath); var script = File.ReadAllText(scriptPath); + var compatibilityWorkflow = File.ReadAllText(compatibilityWorkflowPath); Assert.Contains("../../../global.json", actionYaml, StringComparison.Ordinal); + Assert.Contains("report-path", actionYaml, StringComparison.Ordinal); + Assert.Contains("summary-path", actionYaml, StringComparison.Ordinal); Assert.Contains("../../..", actionYaml, StringComparison.Ordinal); Assert.Contains("../../..", script, StringComparison.Ordinal); + Assert.Contains("./.github/workflows/powerforge-github-housekeeping.yml", compatibilityWorkflow, StringComparison.Ordinal); + Assert.Contains("report-artifact-name", compatibilityWorkflow, StringComparison.Ordinal); } private static string FindRepoRoot() From 350dac23587e65768cbc92f73cfe22ef2cd704db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 09:11:07 +0100 Subject: [PATCH 04/13] Move housekeeping reporting into PowerForge --- .../Invoke-PowerForgeHousekeeping.ps1 | 398 ------------------ .github/actions/github-housekeeping/README.md | 2 +- .../actions/github-housekeeping/action.yml | 13 +- .../reusable-github-housekeeping.yml | 55 --- ...gram.Command.GitHub.HousekeepingOutputs.cs | 60 +++ PowerForge.Cli/Program.Command.GitHub.cs | 5 + .../GitHubHousekeepingActionTests.cs | 13 +- .../GitHubHousekeepingReportServiceTests.cs | 61 +++ PowerForge/Models/GitHubHousekeepingReport.cs | 44 ++ .../GitHubHousekeepingReportService.cs | 295 +++++++++++++ README.MD | 2 +- 11 files changed, 479 insertions(+), 469 deletions(-) delete mode 100644 .github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 delete mode 100644 .github/workflows/reusable-github-housekeeping.yml create mode 100644 PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs create mode 100644 PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs create mode 100644 PowerForge/Models/GitHubHousekeepingReport.cs create mode 100644 PowerForge/Services/GitHubHousekeepingReportService.cs diff --git a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 deleted file mode 100644 index c9e6278d..00000000 --- a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 +++ /dev/null @@ -1,398 +0,0 @@ -[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 Format-NullableGiB { - param($Bytes) - - if ($null -eq $Bytes) { - return '-' - } - - return Format-GiB ([long]$Bytes) -} - -function Format-NullableCount { - param($Value) - - if ($null -eq $Value) { - return '-' - } - - return [string]$Value -} - -function Format-NullableDate { - param($Value) - - if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { - return '-' - } - - return [string]$Value -} - -function Escape-MarkdownCell { - param([string] $Value) - - if ([string]::IsNullOrWhiteSpace($Value)) { - return '-' - } - - return $Value.Replace('|', '\|').Replace("`r", ' ').Replace("`n", '
') -} - -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 Write-GitHubOutput { - param( - [string] $Name, - [string] $Value - ) - - if ([string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) { - return - } - - "{0}={1}" -f $Name, $Value | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append -} - -function Resolve-WorkspacePath { - param( - [string] $ConfiguredPath, - [string] $DefaultRelativePath - ) - - $path = $ConfiguredPath - if ([string]::IsNullOrWhiteSpace($path)) { - $path = $DefaultRelativePath - } - - if ([System.IO.Path]::IsPathRooted($path)) { - return [System.IO.Path]::GetFullPath($path) - } - - if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { - throw 'GITHUB_WORKSPACE is not set.' - } - - return [System.IO.Path]::GetFullPath((Join-Path $env:GITHUB_WORKSPACE $path)) -} - -function Resolve-ConfigPath { - return Resolve-WorkspacePath -ConfiguredPath $env:INPUT_CONFIG_PATH -DefaultRelativePath '.powerforge/github-housekeeping.json' -} - -function Add-SectionTable { - param( - [System.Collections.Generic.List[string]] $Lines, - [object[]] $Rows - ) - - if ($Rows.Count -eq 0) { - return - } - - $Lines.Add('') - $Lines.Add('## Storage Summary') - $Lines.Add('') - $Lines.Add('| Section | Status | Planned | Deleted | Failed | Before | After |') - $Lines.Add('| --- | --- | ---: | ---: | ---: | --- | --- |') - - foreach ($row in $Rows) { - $Lines.Add("| $($row.Section) | $($row.Status) | $($row.Planned) | $($row.Deleted) | $($row.Failed) | $($row.Before) | $($row.After) |") - } -} - -function Add-ItemDetails { - param( - [System.Collections.Generic.List[string]] $Lines, - [string] $Title, - [object[]] $Items, - [string] $Type - ) - - if ($Items.Count -eq 0) { - return - } - - $Lines.Add('') - $Lines.Add("
") - $Lines.Add("$Title ($($Items.Count))") - $Lines.Add('') - - if ($Type -eq 'artifacts') { - $Lines.Add('| Name | Size | Created | Updated | Reason | Delete status |') - $Lines.Add('| --- | ---: | --- | --- | --- | --- |') - foreach ($item in $Items | Select-Object -First 20) { - $deleteState = if ($null -ne $item.deleteError -and -not [string]::IsNullOrWhiteSpace([string]$item.deleteError)) { - "failed ($([string]$item.deleteStatusCode))" - } elseif ($null -ne $item.deleteStatusCode) { - "deleted ($([string]$item.deleteStatusCode))" - } else { - 'planned' - } - - $Lines.Add("| $(Escape-MarkdownCell ([string]$item.name)) | $(Format-GiB ([long]$item.sizeInBytes)) | $(Format-NullableDate $item.createdAt) | $(Format-NullableDate $item.updatedAt) | $(Escape-MarkdownCell ([string]$item.reason)) | $(Escape-MarkdownCell $deleteState) |") - } - } elseif ($Type -eq 'caches') { - $Lines.Add('| Key | Size | Created | Last accessed | Reason | Delete status |') - $Lines.Add('| --- | ---: | --- | --- | --- | --- |') - foreach ($item in $Items | Select-Object -First 20) { - $deleteState = if ($null -ne $item.deleteError -and -not [string]::IsNullOrWhiteSpace([string]$item.deleteError)) { - "failed ($([string]$item.deleteStatusCode))" - } elseif ($null -ne $item.deleteStatusCode) { - "deleted ($([string]$item.deleteStatusCode))" - } else { - 'planned' - } - - $Lines.Add("| $(Escape-MarkdownCell ([string]$item.key)) | $(Format-GiB ([long]$item.sizeInBytes)) | $(Format-NullableDate $item.createdAt) | $(Format-NullableDate $item.lastAccessedAt) | $(Escape-MarkdownCell ([string]$item.reason)) | $(Escape-MarkdownCell $deleteState) |") - } - } - - if ($Items.Count -gt 20) { - $Lines.Add('') - $Lines.Add('_Showing first 20 items._') - } - - $Lines.Add('') - $Lines.Add('
') -} - -function New-HousekeepingSummaryLines { - param([pscustomobject] $Envelope) - - if (-not $Envelope.result) { - return @( - '# PowerForge GitHub Housekeeping Report', - '', - '> ❌ **Housekeeping failed before section results were produced**', - '', - '| Field | Value |', - '| --- | --- |', - "| Success | $(if ($Envelope.success) { 'Yes' } else { 'No' }) |", - "| Exit code | $(Format-NullableCount $Envelope.exitCode) |", - "| Error | $(Escape-MarkdownCell ([string]$Envelope.error)) |" - ) - } - - $result = $Envelope.result - $repository = if ([string]::IsNullOrWhiteSpace([string]$result.repository)) { '(runner-only)' } else { [string]$result.repository } - $statusIcon = if ($Envelope.success) { '✅' } else { '❌' } - $mode = if ($result.dryRun) { 'dry-run' } else { 'apply' } - $rows = [System.Collections.Generic.List[object]]::new() - - if ($result.artifacts) { - $artifactStatus = if ($null -eq $result.artifacts.failedDeletes -or [int]$result.artifacts.failedDeletes -eq 0) { 'ok' } else { 'warnings' } - $rows.Add([pscustomobject]@{ - Section = 'Artifacts' - Status = $artifactStatus - Planned = ("{0} ({1})" -f (Format-NullableCount $result.artifacts.plannedDeletes), (Format-NullableGiB $result.artifacts.plannedDeleteBytes)) - Deleted = ("{0} ({1})" -f (Format-NullableCount $result.artifacts.deletedArtifacts), (Format-NullableGiB $result.artifacts.deletedBytes)) - Failed = Format-NullableCount $result.artifacts.failedDeletes - Before = '-' - After = '-' - }) - } - - if ($result.caches) { - $cacheStatus = if ($null -eq $result.caches.failedDeletes -or [int]$result.caches.failedDeletes -eq 0) { 'ok' } else { 'warnings' } - $cacheBefore = if ($result.caches.usageBefore) { - ("{0} caches / {1}" -f (Format-NullableCount $result.caches.usageBefore.activeCachesCount), (Format-NullableGiB $result.caches.usageBefore.activeCachesSizeInBytes)) - } else { - '-' - } - $cacheAfter = if ($result.caches.usageAfter) { - ("{0} caches / {1}" -f (Format-NullableCount $result.caches.usageAfter.activeCachesCount), (Format-NullableGiB $result.caches.usageAfter.activeCachesSizeInBytes)) - } else { - '-' - } - $rows.Add([pscustomobject]@{ - Section = 'Caches' - Status = $cacheStatus - Planned = ("{0} ({1})" -f (Format-NullableCount $result.caches.plannedDeletes), (Format-NullableGiB $result.caches.plannedDeleteBytes)) - Deleted = ("{0} ({1})" -f (Format-NullableCount $result.caches.deletedCaches), (Format-NullableGiB $result.caches.deletedBytes)) - Failed = Format-NullableCount $result.caches.failedDeletes - Before = $cacheBefore - After = $cacheAfter - }) - } - - if ($result.runner) { - $rows.Add([pscustomobject]@{ - Section = 'Runner' - Status = if ($result.runner.success) { 'ok' } else { 'warnings' } - Planned = '-' - Deleted = '-' - Failed = if ($result.runner.success) { '0' } else { '1' } - Before = Format-NullableGiB $result.runner.freeBytesBefore - After = Format-NullableGiB $result.runner.freeBytesAfter - }) - } - - $lines = [System.Collections.Generic.List[string]]::new() - $lines.Add('# PowerForge GitHub Housekeeping Report') - $lines.Add('') - $lines.Add("> $statusIcon **$repository** ran in **$mode** mode") - $lines.Add('') - $lines.Add('| Field | Value |') - $lines.Add('| --- | --- |') - $lines.Add("| Success | $(if ($Envelope.success) { 'Yes' } else { 'No' }) |") - $lines.Add("| Requested sections | $(Escape-MarkdownCell ((@($result.requestedSections) -join ', '))) |") - $lines.Add("| Completed sections | $(Escape-MarkdownCell ((@($result.completedSections) -join ', '))) |") - $lines.Add("| Failed sections | $(Escape-MarkdownCell ((@($result.failedSections) -join ', '))) |") - - if ($result.message) { - $lines.Add("| Message | $(Escape-MarkdownCell ([string]$result.message)) |") - } - - Add-SectionTable -Lines $lines -Rows $rows.ToArray() - - if ($result.artifacts -and $result.artifacts.items) { - Add-ItemDetails -Lines $lines -Title 'Artifact selection details' -Items @($result.artifacts.items) -Type 'artifacts' - } - - if ($result.caches -and $result.caches.items) { - Add-ItemDetails -Lines $lines -Title 'Cache selection details' -Items @($result.caches.items) -Type 'caches' - } - - return $lines.ToArray() -} - -function Write-TextFile { - param( - [string] $Path, - [string[]] $Lines - ) - - $directory = Split-Path -Parent $Path - if (-not [string]::IsNullOrWhiteSpace($directory)) { - New-Item -ItemType Directory -Path $directory -Force | Out-Null - } - - Set-Content -LiteralPath $Path -Value ($Lines -join [Environment]::NewLine) -Encoding utf8 -} - -function Write-JsonFile { - param( - [string] $Path, - [string] $RawJson - ) - - $directory = Split-Path -Parent $Path - if (-not [string]::IsNullOrWhiteSpace($directory)) { - New-Item -ItemType Directory -Path $directory -Force | Out-Null - } - - Set-Content -LiteralPath $Path -Value $RawJson -Encoding utf8 -} - -function Write-HousekeepingSummary { - param( - [pscustomobject] $Envelope, - [string] $ReportPath, - [string] $SummaryPath, - [string] $RawJson - ) - - $lines = New-HousekeepingSummaryLines -Envelope $Envelope - - if ($Envelope.result) { - $result = $Envelope.result - Write-Host ("GitHub housekeeping: requested={0}; completed={1}; failed={2}" -f ` - (@($result.requestedSections) -join ','), ` - (@($result.completedSections) -join ','), ` - (@($result.failedSections) -join ',')) - } else { - Write-Host ("GitHub housekeeping failed before a detailed result was produced: {0}" -f ([string]$Envelope.error)) - } - - Write-MarkdownSummary -Lines ($lines + '') - Write-TextFile -Path $SummaryPath -Lines ($lines + '') - Write-JsonFile -Path $ReportPath -RawJson $RawJson - Write-GitHubOutput -Name 'report-path' -Value $ReportPath - Write-GitHubOutput -Name 'summary-path' -Value $SummaryPath -} - -$configPath = Resolve-ConfigPath -$reportPath = Resolve-WorkspacePath -ConfiguredPath $env:INPUT_REPORT_PATH -DefaultRelativePath '.powerforge/_reports/github-housekeeping.json' -$summaryPath = Resolve-WorkspacePath -ConfiguredPath $env:INPUT_SUMMARY_PATH -DefaultRelativePath '.powerforge/_reports/github-housekeeping.md' - -if (-not (Test-Path -LiteralPath $configPath)) { - throw "Housekeeping config not found: $configPath" -} - -$arguments = [System.Collections.Generic.List[string]]::new() -foreach ($argument in @( - 'run', '--project', $project, '--framework', 'net10.0', '-c', 'Release', '--no-build', '--', - 'github', 'housekeeping', - '--config', $configPath -)) { - $null = $arguments.Add([string]$argument) -} - -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-env') - $null = $arguments.Add('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 -ReportPath $reportPath -SummaryPath $summaryPath -RawJson $rawOutput - -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 index 2bf3a937..5a967270 100644 --- a/.github/actions/github-housekeeping/README.md +++ b/.github/actions/github-housekeeping/README.md @@ -80,5 +80,5 @@ jobs: - 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. -- The legacy workflow file `reusable-github-housekeeping.yml` remains as a compatibility alias, but `powerforge-github-housekeeping.yml` is the recommended public entrypoint. +- The public reusable workflow entrypoint is `powerforge-github-housekeeping.yml`. - The composite action exposes `report-path` and `summary-path` outputs for callers that want to publish the generated reports elsewhere. diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml index 0e8bed7e..72fb39db 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -52,12 +52,17 @@ runs: - name: Run GitHub housekeeping id: housekeeping shell: pwsh - run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 + run: | + $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../../..")).Path + $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" + $args = @('run', '--project', $project, '-c', 'Release', '--no-build', '--framework', 'net10.0', '--', 'github', 'housekeeping', '--config', $env:INPUT_CONFIG_PATH) + if ($env:INPUT_APPLY -eq 'true') { $args += '--apply' } else { $args += '--dry-run' } + & dotnet @args env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: 1 INPUT_CONFIG_PATH: ${{ inputs['config-path'] }} INPUT_APPLY: ${{ inputs.apply }} - INPUT_REPORT_PATH: ${{ inputs['report-path'] }} - INPUT_SUMMARY_PATH: ${{ inputs['summary-path'] }} - POWERFORGE_GITHUB_TOKEN: ${{ inputs['github-token'] != '' && inputs['github-token'] || github.token }} + GITHUB_TOKEN: ${{ inputs['github-token'] != '' && inputs['github-token'] || github.token }} + POWERFORGE_GITHUB_HOUSEKEEPING_REPORT_PATH: ${{ inputs['report-path'] }} + POWERFORGE_GITHUB_HOUSEKEEPING_SUMMARY_PATH: ${{ inputs['summary-path'] }} diff --git a/.github/workflows/reusable-github-housekeeping.yml b/.github/workflows/reusable-github-housekeeping.yml deleted file mode 100644 index 44ed9934..00000000 --- a/.github/workflows/reusable-github-housekeeping.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Reusable GitHub Housekeeping (Compatibility) - -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 - report-path: - description: Optional JSON report path relative to the caller workspace. - required: false - default: ".powerforge/_reports/github-housekeeping.json" - type: string - summary-path: - description: Optional Markdown summary path relative to the caller workspace. - required: false - default: ".powerforge/_reports/github-housekeeping.md" - type: string - report-artifact-name: - description: Artifact name used when uploading housekeeping reports. - required: false - default: "powerforge-github-housekeeping-reports" - type: string - secrets: - github-token: - required: false - -permissions: - actions: write - contents: read - -jobs: - housekeeping: - uses: ./.github/workflows/powerforge-github-housekeeping.yml - with: - config-path: ${{ inputs['config-path'] }} - apply: ${{ inputs.apply }} - powerforge-ref: ${{ inputs['powerforge-ref'] }} - report-path: ${{ inputs['report-path'] }} - summary-path: ${{ inputs['summary-path'] }} - report-artifact-name: ${{ inputs['report-artifact-name'] }} - secrets: - github-token: ${{ secrets['github-token'] }} diff --git a/PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs b/PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs new file mode 100644 index 00000000..4d02768c --- /dev/null +++ b/PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs @@ -0,0 +1,60 @@ +using PowerForge; +using System.Text; + +internal static partial class Program +{ + private sealed class GitHubHousekeepingOutputOptions + { + public string? JsonReportPath { get; init; } + public string? MarkdownReportPath { get; init; } + public string? StepSummaryPath { get; init; } + public string? GitHubOutputPath { get; init; } + } + + private static GitHubHousekeepingOutputOptions GetGitHubHousekeepingOutputOptions() + { + var baseDir = Directory.GetCurrentDirectory(); + return new GitHubHousekeepingOutputOptions + { + JsonReportPath = ResolvePathFromBaseNullable(baseDir, Environment.GetEnvironmentVariable("POWERFORGE_GITHUB_HOUSEKEEPING_REPORT_PATH")), + MarkdownReportPath = ResolvePathFromBaseNullable(baseDir, Environment.GetEnvironmentVariable("POWERFORGE_GITHUB_HOUSEKEEPING_SUMMARY_PATH")), + StepSummaryPath = ResolvePathFromBaseNullable(baseDir, Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY")), + GitHubOutputPath = ResolvePathFromBaseNullable(baseDir, Environment.GetEnvironmentVariable("GITHUB_OUTPUT")) + }; + } + + private static void WriteGitHubHousekeepingOutputs( + GitHubHousekeepingReportService reports, + GitHubHousekeepingReport report, + GitHubHousekeepingOutputOptions options) + { + var markdown = reports.BuildMarkdown(report); + + if (!string.IsNullOrWhiteSpace(options.JsonReportPath)) + reports.WriteJsonReport(options.JsonReportPath, report); + + if (!string.IsNullOrWhiteSpace(options.MarkdownReportPath)) + reports.WriteMarkdownReport(options.MarkdownReportPath, report); + + if (!string.IsNullOrWhiteSpace(options.StepSummaryPath)) + AppendUtf8(options.StepSummaryPath, markdown + Environment.NewLine); + + if (!string.IsNullOrWhiteSpace(options.GitHubOutputPath)) + { + if (!string.IsNullOrWhiteSpace(options.JsonReportPath)) + AppendUtf8(options.GitHubOutputPath, $"report-path={options.JsonReportPath}{Environment.NewLine}"); + if (!string.IsNullOrWhiteSpace(options.MarkdownReportPath)) + AppendUtf8(options.GitHubOutputPath, $"summary-path={options.MarkdownReportPath}{Environment.NewLine}"); + } + } + + private static void AppendUtf8(string path, string content) + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + File.AppendAllText(fullPath, content, new UTF8Encoding(false)); + } +} diff --git a/PowerForge.Cli/Program.Command.GitHub.cs b/PowerForge.Cli/Program.Command.GitHub.cs index 2b271751..143b7f12 100644 --- a/PowerForge.Cli/Program.Command.GitHub.cs +++ b/PowerForge.Cli/Program.Command.GitHub.cs @@ -246,6 +246,8 @@ private static int CommandGitHubRunner(string[] argv, CliOptions cli, ILogger lo private static int CommandGitHubHousekeeping(string[] argv, CliOptions cli, ILogger logger) { var outputJson = IsJsonOutput(argv); + var outputOptions = GetGitHubHousekeepingOutputOptions(); + var reportService = new GitHubHousekeepingReportService(); if (argv.Length > 0 && IsHelpArg(argv[0])) { Console.WriteLine(GitHubHousekeepingUsage); @@ -259,6 +261,7 @@ private static int CommandGitHubHousekeeping(string[] argv, CliOptions cli, ILog } catch (Exception ex) { + WriteGitHubHousekeepingOutputs(reportService, reportService.CreateFailureReport(2, ex.Message), outputOptions); return WriteGitHubCommandArgumentError(outputJson, "github.housekeeping", ex.Message, GitHubHousekeepingUsage, logger); } @@ -269,6 +272,7 @@ private static int CommandGitHubHousekeeping(string[] argv, CliOptions cli, ILog 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; + WriteGitHubHousekeepingOutputs(reportService, reportService.CreateSuccessReport(result), outputOptions); if (outputJson) { @@ -304,6 +308,7 @@ private static int CommandGitHubHousekeeping(string[] argv, CliOptions cli, ILog } catch (Exception ex) { + WriteGitHubHousekeepingOutputs(reportService, reportService.CreateFailureReport(1, ex.Message), outputOptions); return WriteGitHubCommandFailure(outputJson, "github.housekeeping", ex.Message, logger); } } diff --git a/PowerForge.Tests/GitHubHousekeepingActionTests.cs b/PowerForge.Tests/GitHubHousekeepingActionTests.cs index 9afb75f2..588f1ebd 100644 --- a/PowerForge.Tests/GitHubHousekeepingActionTests.cs +++ b/PowerForge.Tests/GitHubHousekeepingActionTests.cs @@ -8,15 +8,11 @@ public void CompositeAction_AssetPaths_ShouldResolveFromActionDirectory() var repoRoot = FindRepoRoot(); var actionRoot = Path.Combine(repoRoot, ".github", "actions", "github-housekeeping"); var actionYamlPath = Path.Combine(actionRoot, "action.yml"); - var scriptPath = Path.Combine(actionRoot, "Invoke-PowerForgeHousekeeping.ps1"); var publicWorkflowPath = Path.Combine(repoRoot, ".github", "workflows", "powerforge-github-housekeeping.yml"); - var compatibilityWorkflowPath = Path.Combine(repoRoot, ".github", "workflows", "reusable-github-housekeeping.yml"); Assert.True(Directory.Exists(actionRoot), $"Action directory not found: {actionRoot}"); Assert.True(File.Exists(actionYamlPath), $"Composite action definition not found: {actionYamlPath}"); - Assert.True(File.Exists(scriptPath), $"Composite action script not found: {scriptPath}"); Assert.True(File.Exists(publicWorkflowPath), $"Public reusable workflow not found: {publicWorkflowPath}"); - Assert.True(File.Exists(compatibilityWorkflowPath), $"Compatibility reusable workflow not found: {compatibilityWorkflowPath}"); var globalJsonPath = Path.GetFullPath(Path.Combine(actionRoot, "..", "..", "..", "global.json")); var cliProjectPath = Path.GetFullPath(Path.Combine(actionRoot, "..", "..", "..", "PowerForge.Cli", "PowerForge.Cli.csproj")); @@ -25,16 +21,13 @@ public void CompositeAction_AssetPaths_ShouldResolveFromActionDirectory() Assert.True(File.Exists(cliProjectPath), $"PowerForge.Cli project should resolve from composite action directory: {cliProjectPath}"); var actionYaml = File.ReadAllText(actionYamlPath); - var script = File.ReadAllText(scriptPath); - var compatibilityWorkflow = File.ReadAllText(compatibilityWorkflowPath); - Assert.Contains("../../../global.json", actionYaml, StringComparison.Ordinal); Assert.Contains("report-path", actionYaml, StringComparison.Ordinal); Assert.Contains("summary-path", actionYaml, StringComparison.Ordinal); Assert.Contains("../../..", actionYaml, StringComparison.Ordinal); - Assert.Contains("../../..", script, StringComparison.Ordinal); - Assert.Contains("./.github/workflows/powerforge-github-housekeeping.yml", compatibilityWorkflow, StringComparison.Ordinal); - Assert.Contains("report-artifact-name", compatibilityWorkflow, StringComparison.Ordinal); + Assert.Contains("PowerForge GitHub Housekeeping", actionYaml, StringComparison.Ordinal); + Assert.Contains("POWERFORGE_GITHUB_HOUSEKEEPING_REPORT_PATH", actionYaml, StringComparison.Ordinal); + Assert.DoesNotContain("Invoke-GitHubHousekeeping.ps1", actionYaml, StringComparison.Ordinal); } private static string FindRepoRoot() diff --git a/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs b/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs new file mode 100644 index 00000000..763ad6f8 --- /dev/null +++ b/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs @@ -0,0 +1,61 @@ +namespace PowerForge.Tests; + +public sealed class GitHubHousekeepingReportServiceTests +{ + [Fact] + public void BuildMarkdown_ShouldIncludeSectionSummaryAndDetails() + { + var service = new GitHubHousekeepingReportService(); + var report = service.CreateSuccessReport(new GitHubHousekeepingResult + { + Repository = "EvotecIT/TestRepo", + DryRun = true, + Success = true, + RequestedSections = ["artifacts", "caches"], + CompletedSections = ["artifacts", "caches"], + Artifacts = new GitHubArtifactCleanupResult + { + Success = true, + PlannedDeletes = 1, + PlannedDeleteBytes = 1024, + Planned = + [ + new GitHubArtifactCleanupItem + { + Name = "github-pages", + SizeInBytes = 1024, + Reason = "older duplicate" + } + ] + }, + Caches = new GitHubActionsCacheCleanupResult + { + Success = true, + UsageBefore = new GitHubActionsCacheUsage + { + ActiveCachesCount = 3, + ActiveCachesSizeInBytes = 4096 + } + } + }); + + var markdown = service.BuildMarkdown(report); + + Assert.Contains("PowerForge GitHub Housekeeping Report", markdown, StringComparison.Ordinal); + Assert.Contains("Storage Summary", markdown, StringComparison.Ordinal); + Assert.Contains("Planned artifacts (1)", markdown, StringComparison.Ordinal); + Assert.Contains("github-pages", markdown, StringComparison.Ordinal); + Assert.Contains("3 caches", markdown, StringComparison.Ordinal); + } + + [Fact] + public void BuildMarkdown_ShouldRenderFailureWithoutResult() + { + var service = new GitHubHousekeepingReportService(); + + var markdown = service.BuildMarkdown(service.CreateFailureReport(1, "Bad credentials")); + + Assert.Contains("Housekeeping failed before section results were produced", markdown, StringComparison.Ordinal); + Assert.Contains("Bad credentials", markdown, StringComparison.Ordinal); + } +} diff --git a/PowerForge/Models/GitHubHousekeepingReport.cs b/PowerForge/Models/GitHubHousekeepingReport.cs new file mode 100644 index 00000000..7c730520 --- /dev/null +++ b/PowerForge/Models/GitHubHousekeepingReport.cs @@ -0,0 +1,44 @@ +using System; + +namespace PowerForge; + +/// +/// Portable report payload for a GitHub housekeeping run. +/// +public sealed class GitHubHousekeepingReport +{ + /// + /// Report schema version. + /// + public int SchemaVersion { get; set; } = 1; + + /// + /// Command identifier that produced the report. + /// + public string Command { get; set; } = "github.housekeeping"; + + /// + /// Whether the housekeeping run succeeded. + /// + public bool Success { get; set; } + + /// + /// Process exit code associated with the report. + /// + public int ExitCode { get; set; } + + /// + /// Error text when the run failed before producing a result. + /// + public string? Error { get; set; } + + /// + /// UTC timestamp when the report was generated. + /// + public DateTimeOffset GeneratedAtUtc { get; set; } = DateTimeOffset.UtcNow; + + /// + /// Housekeeping result payload when available. + /// + public GitHubHousekeepingResult? Result { get; set; } +} diff --git a/PowerForge/Services/GitHubHousekeepingReportService.cs b/PowerForge/Services/GitHubHousekeepingReportService.cs new file mode 100644 index 00000000..1eab8374 --- /dev/null +++ b/PowerForge/Services/GitHubHousekeepingReportService.cs @@ -0,0 +1,295 @@ +using System.Text; +using System.Text.Json; + +namespace PowerForge; + +/// +/// Builds portable JSON and Markdown reports for GitHub housekeeping runs. +/// +public sealed class GitHubHousekeepingReportService +{ + private static readonly JsonSerializerOptions ReportJsonOptions = new() + { + WriteIndented = true + }; + + /// + /// Creates a report payload from a completed housekeeping result. + /// + /// Completed housekeeping result. + /// Portable housekeeping report payload. + public GitHubHousekeepingReport CreateSuccessReport(GitHubHousekeepingResult result) + { + ArgumentNullException.ThrowIfNull(result); + + return new GitHubHousekeepingReport + { + Success = result.Success, + ExitCode = result.Success ? 0 : 1, + Result = result + }; + } + + /// + /// Creates a failure report when the housekeeping command fails before producing a result. + /// + /// Associated process exit code. + /// Failure message. + /// Portable failure report payload. + public GitHubHousekeepingReport CreateFailureReport(int exitCode, string error) + { + return new GitHubHousekeepingReport + { + Success = false, + ExitCode = exitCode, + Error = error + }; + } + + /// + /// Serializes a housekeeping report to indented JSON. + /// + /// Report payload to serialize. + /// Indented JSON report. + public string BuildJson(GitHubHousekeepingReport report) + { + ArgumentNullException.ThrowIfNull(report); + return JsonSerializer.Serialize(report, ReportJsonOptions); + } + + /// + /// Renders a human-readable Markdown report for GitHub Actions summaries and artifacts. + /// + /// Report payload to render. + /// Markdown representation of the report. + public string BuildMarkdown(GitHubHousekeepingReport report) + { + ArgumentNullException.ThrowIfNull(report); + + var markdown = new StringBuilder(); + markdown.AppendLine("# PowerForge GitHub Housekeeping Report"); + markdown.AppendLine(); + + if (report.Result is null) + { + markdown.AppendLine("> ❌ **Housekeeping failed before section results were produced**"); + markdown.AppendLine(); + markdown.AppendLine("| Field | Value |"); + markdown.AppendLine("| --- | --- |"); + markdown.AppendLine($"| Success | {(report.Success ? "Yes" : "No")} |"); + markdown.AppendLine($"| Exit code | {report.ExitCode} |"); + markdown.AppendLine($"| Error | {EscapeCell(report.Error)} |"); + return markdown.ToString(); + } + + var result = report.Result; + var repository = string.IsNullOrWhiteSpace(result.Repository) ? "(runner-only)" : result.Repository; + markdown.AppendLine($"> {(report.Success ? "✅" : "❌")} **{repository}** ran in **{(result.DryRun ? "dry-run" : "apply")}** mode"); + markdown.AppendLine(); + markdown.AppendLine("| Field | Value |"); + markdown.AppendLine("| --- | --- |"); + markdown.AppendLine($"| Success | {(report.Success ? "Yes" : "No")} |"); + markdown.AppendLine($"| Requested sections | {EscapeCell(string.Join(", ", result.RequestedSections))} |"); + markdown.AppendLine($"| Completed sections | {EscapeCell(string.Join(", ", result.CompletedSections))} |"); + markdown.AppendLine($"| Failed sections | {EscapeCell(string.Join(", ", result.FailedSections))} |"); + if (!string.IsNullOrWhiteSpace(result.Message)) + markdown.AppendLine($"| Message | {EscapeCell(result.Message)} |"); + + AppendStorageSummary(markdown, result); + AppendArtifactDetails(markdown, result.Artifacts); + AppendCacheDetails(markdown, result.Caches); + AppendRunnerDetails(markdown, result.Runner); + return markdown.ToString(); + } + + /// + /// Writes the JSON report to disk using UTF-8 without BOM. + /// + /// Destination file path. + /// Report payload to persist. + public void WriteJsonReport(string path, GitHubHousekeepingReport report) + => WriteUtf8(path, BuildJson(report)); + + /// + /// Writes the Markdown report to disk using UTF-8 without BOM. + /// + /// Destination file path. + /// Report payload to persist. + public void WriteMarkdownReport(string path, GitHubHousekeepingReport report) + => WriteUtf8(path, BuildMarkdown(report)); + + private static void AppendStorageSummary(StringBuilder markdown, GitHubHousekeepingResult result) + { + var rows = new List(); + + if (result.Artifacts is not null) + { + rows.Add($"| Artifacts | {Status(result.Artifacts.Success, result.Artifacts.FailedDeletes)} | {CountAndBytes(result.Artifacts.PlannedDeletes, result.Artifacts.PlannedDeleteBytes)} | {CountAndBytes(result.Artifacts.DeletedArtifacts, result.Artifacts.DeletedBytes)} | {result.Artifacts.FailedDeletes} | - | - |"); + } + + if (result.Caches is not null) + { + rows.Add($"| Caches | {Status(result.Caches.Success, result.Caches.FailedDeletes)} | {CountAndBytes(result.Caches.PlannedDeletes, result.Caches.PlannedDeleteBytes)} | {CountAndBytes(result.Caches.DeletedCaches, result.Caches.DeletedBytes)} | {result.Caches.FailedDeletes} | {CacheUsage(result.Caches.UsageBefore)} | {CacheUsage(result.Caches.UsageAfter)} |"); + } + + if (result.Runner is not null) + { + rows.Add($"| Runner | {Status(result.Runner.Success, result.Runner.Success ? 0 : 1)} | - | - | {(result.Runner.Success ? "0" : "1")} | {FormatGiB(result.Runner.FreeBytesBefore)} | {FormatGiB(result.Runner.FreeBytesAfter)} |"); + } + + if (rows.Count == 0) + return; + + markdown.AppendLine(); + markdown.AppendLine("## Storage Summary"); + markdown.AppendLine(); + markdown.AppendLine("| Section | Status | Planned | Deleted | Failed | Before | After |"); + markdown.AppendLine("| --- | --- | ---: | ---: | ---: | --- | --- |"); + foreach (var row in rows) + markdown.AppendLine(row); + } + + private static void AppendArtifactDetails(StringBuilder markdown, GitHubArtifactCleanupResult? result) + { + if (result is null) + return; + + AppendArtifactTable(markdown, "Planned artifacts", result.Planned); + AppendArtifactTable(markdown, "Deleted artifacts", result.Deleted); + AppendArtifactTable(markdown, "Failed artifacts", result.Failed); + } + + private static void AppendArtifactTable(StringBuilder markdown, string title, IReadOnlyList items) + { + if (items.Count == 0) + return; + + markdown.AppendLine(); + markdown.AppendLine("
"); + markdown.AppendLine($"{title} ({items.Count})"); + markdown.AppendLine(); + markdown.AppendLine("| Name | Size | Created | Updated | Reason | Delete status |"); + markdown.AppendLine("| --- | ---: | --- | --- | --- | --- |"); + + foreach (var item in items.Take(20)) + { + markdown.AppendLine($"| {EscapeCell(item.Name)} | {FormatGiB(item.SizeInBytes)} | {FormatDate(item.CreatedAt)} | {FormatDate(item.UpdatedAt)} | {EscapeCell(item.Reason)} | {EscapeCell(DeleteState(item.DeleteStatusCode, item.DeleteError))} |"); + } + + AppendTruncationNotice(markdown, items.Count); + markdown.AppendLine(); + markdown.AppendLine("
"); + } + + private static void AppendCacheDetails(StringBuilder markdown, GitHubActionsCacheCleanupResult? result) + { + if (result is null) + return; + + AppendCacheTable(markdown, "Planned caches", result.Planned); + AppendCacheTable(markdown, "Deleted caches", result.Deleted); + AppendCacheTable(markdown, "Failed caches", result.Failed); + } + + private static void AppendCacheTable(StringBuilder markdown, string title, IReadOnlyList items) + { + if (items.Count == 0) + return; + + markdown.AppendLine(); + markdown.AppendLine("
"); + markdown.AppendLine($"{title} ({items.Count})"); + markdown.AppendLine(); + markdown.AppendLine("| Key | Size | Created | Last accessed | Reason | Delete status |"); + markdown.AppendLine("| --- | ---: | --- | --- | --- | --- |"); + + foreach (var item in items.Take(20)) + { + markdown.AppendLine($"| {EscapeCell(item.Key)} | {FormatGiB(item.SizeInBytes)} | {FormatDate(item.CreatedAt)} | {FormatDate(item.LastAccessedAt)} | {EscapeCell(item.Reason)} | {EscapeCell(DeleteState(item.DeleteStatusCode, item.DeleteError))} |"); + } + + AppendTruncationNotice(markdown, items.Count); + markdown.AppendLine(); + markdown.AppendLine("
"); + } + + private static void AppendRunnerDetails(StringBuilder markdown, RunnerHousekeepingResult? result) + { + if (result is null || result.Steps.Length == 0) + return; + + markdown.AppendLine(); + markdown.AppendLine("
"); + markdown.AppendLine($"Runner steps ({result.Steps.Length})"); + markdown.AppendLine(); + markdown.AppendLine("| Step | Status | Entries | Message |"); + markdown.AppendLine("| --- | --- | ---: | --- |"); + + foreach (var step in result.Steps.Take(20)) + { + markdown.AppendLine($"| {EscapeCell(step.Title)} | {(step.Success ? "ok" : "warning")} | {step.EntriesAffected} | {EscapeCell(step.Message)} |"); + } + + AppendTruncationNotice(markdown, result.Steps.Length); + markdown.AppendLine(); + markdown.AppendLine("
"); + } + + private static void AppendTruncationNotice(StringBuilder markdown, int count) + { + if (count > 20) + { + markdown.AppendLine(); + markdown.AppendLine("_Showing first 20 items._"); + } + } + + private static string CountAndBytes(int count, long bytes) + => $"{count} ({FormatGiB(bytes)})"; + + private static string CacheUsage(GitHubActionsCacheUsage? usage) + => usage is null ? "-" : $"{usage.ActiveCachesCount} caches / {FormatGiB(usage.ActiveCachesSizeInBytes)}"; + + private static string Status(bool success, int failed) + => !success ? "failed" : failed > 0 ? "warnings" : "ok"; + + private static string DeleteState(int? statusCode, string? error) + { + if (!string.IsNullOrWhiteSpace(error)) + return $"failed ({statusCode?.ToString() ?? "error"})"; + if (statusCode.HasValue) + return $"deleted ({statusCode.Value})"; + return "planned"; + } + + private static string FormatDate(DateTimeOffset? value) + => value?.ToString("u") ?? "-"; + + private static string EscapeCell(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "-"; + + return value.Replace("|", "\\|", StringComparison.Ordinal) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", "
", StringComparison.Ordinal); + } + + private static string FormatGiB(long bytes) + { + if (bytes <= 0) + return "0.0 GiB"; + + return $"{bytes / (double)(1L << 30):N1} GiB"; + } + + private static void WriteUtf8(string path, string content) + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(fullPath, content, new UTF8Encoding(false)); + } +} diff --git a/README.MD b/README.MD index da012eda..eeec99b4 100644 --- a/README.MD +++ b/README.MD @@ -197,7 +197,7 @@ permissions: jobs: housekeeping: - uses: EvotecIT/PSPublishModule/.github/workflows/reusable-github-housekeeping.yml@main + uses: EvotecIT/PSPublishModule/.github/workflows/powerforge-github-housekeeping.yml@main with: config-path: ./.powerforge/github-housekeeping.json secrets: inherit From 8d40c3b91e724a328c78dcd4b9fa1ec738695de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 09:33:33 +0100 Subject: [PATCH 05/13] Fix housekeeping CI and schedule gating --- .github/workflows/github-housekeeping.yml | 2 +- .../Services/GitHubHousekeepingReportService.cs | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/github-housekeeping.yml b/.github/workflows/github-housekeeping.yml index f2e03a75..b7b57567 100644 --- a/.github/workflows/github-housekeeping.yml +++ b/.github/workflows/github-housekeeping.yml @@ -23,7 +23,7 @@ jobs: uses: ./.github/workflows/powerforge-github-housekeeping.yml with: config-path: ./.powerforge/github-housekeeping.json - apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply == 'true' || github.event_name != 'workflow_dispatch' }} + apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply == 'true' || github.event_name != 'workflow_dispatch' && vars.POWERFORGE_GITHUB_HOUSEKEEPING_APPLY == 'true' }} powerforge-ref: ${{ github.sha }} secrets: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/PowerForge/Services/GitHubHousekeepingReportService.cs b/PowerForge/Services/GitHubHousekeepingReportService.cs index 1eab8374..bcaa7f44 100644 --- a/PowerForge/Services/GitHubHousekeepingReportService.cs +++ b/PowerForge/Services/GitHubHousekeepingReportService.cs @@ -20,7 +20,8 @@ public sealed class GitHubHousekeepingReportService /// Portable housekeeping report payload. public GitHubHousekeepingReport CreateSuccessReport(GitHubHousekeepingResult result) { - ArgumentNullException.ThrowIfNull(result); + if (result is null) + throw new ArgumentNullException(nameof(result)); return new GitHubHousekeepingReport { @@ -53,7 +54,8 @@ public GitHubHousekeepingReport CreateFailureReport(int exitCode, string error) /// Indented JSON report. public string BuildJson(GitHubHousekeepingReport report) { - ArgumentNullException.ThrowIfNull(report); + if (report is null) + throw new ArgumentNullException(nameof(report)); return JsonSerializer.Serialize(report, ReportJsonOptions); } @@ -64,7 +66,8 @@ public string BuildJson(GitHubHousekeepingReport report) /// Markdown representation of the report. public string BuildMarkdown(GitHubHousekeepingReport report) { - ArgumentNullException.ThrowIfNull(report); + if (report is null) + throw new ArgumentNullException(nameof(report)); var markdown = new StringBuilder(); markdown.AppendLine("# PowerForge GitHub Housekeeping Report"); @@ -270,9 +273,10 @@ private static string EscapeCell(string? value) if (string.IsNullOrWhiteSpace(value)) return "-"; - return value.Replace("|", "\\|", StringComparison.Ordinal) - .Replace("\r", " ", StringComparison.Ordinal) - .Replace("\n", "
", StringComparison.Ordinal); + var text = value ?? string.Empty; + return text.Replace("|", "\\|") + .Replace("\r", " ") + .Replace("\n", "
"); } private static string FormatGiB(long bytes) From 61cbc90da0c81550c00fd938679f43947c21ac60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 09:47:25 +0100 Subject: [PATCH 06/13] Default manual housekeeping to dry-run --- .github/workflows/github-housekeeping.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github-housekeeping.yml b/.github/workflows/github-housekeeping.yml index b7b57567..8c7e3e75 100644 --- a/.github/workflows/github-housekeeping.yml +++ b/.github/workflows/github-housekeeping.yml @@ -8,7 +8,7 @@ on: apply: description: 'Apply deletions (true/false)' required: false - default: 'true' + default: 'false' permissions: actions: write From e8bc3752763afc9eaa53f1200ccfa8beb692d07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 12:13:51 +0100 Subject: [PATCH 07/13] Clarify housekeeping apply expression --- .github/workflows/github-housekeeping.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github-housekeeping.yml b/.github/workflows/github-housekeeping.yml index 8c7e3e75..48516fa6 100644 --- a/.github/workflows/github-housekeeping.yml +++ b/.github/workflows/github-housekeeping.yml @@ -23,7 +23,7 @@ jobs: uses: ./.github/workflows/powerforge-github-housekeeping.yml with: config-path: ./.powerforge/github-housekeeping.json - apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply == 'true' || github.event_name != 'workflow_dispatch' && vars.POWERFORGE_GITHUB_HOUSEKEEPING_APPLY == 'true' }} + apply: ${{ (github.event_name == 'workflow_dispatch' && inputs.apply == 'true') || (github.event_name != 'workflow_dispatch' && vars.POWERFORGE_GITHUB_HOUSEKEEPING_APPLY == 'true') }} powerforge-ref: ${{ github.sha }} secrets: github-token: ${{ secrets.GITHUB_TOKEN }} From e51aa61a8b4f27ea63f861fc34474b9762c3166c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 13:54:35 +0100 Subject: [PATCH 08/13] Explain housekeeping eligibility in reports --- .../GitHubHousekeepingReportServiceTests.cs | 17 +++++- .../GitHubHousekeepingReportService.cs | 60 +++++++++++++++++-- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs b/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs index 763ad6f8..7bce695b 100644 --- a/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs +++ b/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs @@ -16,6 +16,10 @@ public void BuildMarkdown_ShouldIncludeSectionSummaryAndDetails() Artifacts = new GitHubArtifactCleanupResult { Success = true, + ScannedArtifacts = 3, + MatchedArtifacts = 1, + KeptByRecentWindow = 0, + KeptByAgeThreshold = 0, PlannedDeletes = 1, PlannedDeleteBytes = 1024, Planned = @@ -31,10 +35,14 @@ public void BuildMarkdown_ShouldIncludeSectionSummaryAndDetails() Caches = new GitHubActionsCacheCleanupResult { Success = true, + ScannedCaches = 29, + MatchedCaches = 29, + KeptByRecentWindow = 25, + KeptByAgeThreshold = 4, UsageBefore = new GitHubActionsCacheUsage { - ActiveCachesCount = 3, - ActiveCachesSizeInBytes = 4096 + ActiveCachesCount = 29, + ActiveCachesSizeInBytes = 10053309332 } } }); @@ -43,9 +51,12 @@ public void BuildMarkdown_ShouldIncludeSectionSummaryAndDetails() Assert.Contains("PowerForge GitHub Housekeeping Report", markdown, StringComparison.Ordinal); Assert.Contains("Storage Summary", markdown, StringComparison.Ordinal); + Assert.Contains("Selection Breakdown", markdown, StringComparison.Ordinal); Assert.Contains("Planned artifacts (1)", markdown, StringComparison.Ordinal); Assert.Contains("github-pages", markdown, StringComparison.Ordinal); - Assert.Contains("3 caches", markdown, StringComparison.Ordinal); + Assert.Contains("29 caches", markdown, StringComparison.Ordinal); + Assert.Contains("nothing eligible", markdown, StringComparison.Ordinal); + Assert.Contains("all matched items were retained by current policy", markdown, StringComparison.Ordinal); } [Fact] diff --git a/PowerForge/Services/GitHubHousekeepingReportService.cs b/PowerForge/Services/GitHubHousekeepingReportService.cs index bcaa7f44..657998ba 100644 --- a/PowerForge/Services/GitHubHousekeepingReportService.cs +++ b/PowerForge/Services/GitHubHousekeepingReportService.cs @@ -99,6 +99,7 @@ public string BuildMarkdown(GitHubHousekeepingReport report) markdown.AppendLine($"| Message | {EscapeCell(result.Message)} |"); AppendStorageSummary(markdown, result); + AppendSelectionSummary(markdown, result); AppendArtifactDetails(markdown, result.Artifacts); AppendCacheDetails(markdown, result.Caches); AppendRunnerDetails(markdown, result.Runner); @@ -127,17 +128,17 @@ private static void AppendStorageSummary(StringBuilder markdown, GitHubHousekeep if (result.Artifacts is not null) { - rows.Add($"| Artifacts | {Status(result.Artifacts.Success, result.Artifacts.FailedDeletes)} | {CountAndBytes(result.Artifacts.PlannedDeletes, result.Artifacts.PlannedDeleteBytes)} | {CountAndBytes(result.Artifacts.DeletedArtifacts, result.Artifacts.DeletedBytes)} | {result.Artifacts.FailedDeletes} | - | - |"); + rows.Add($"| Artifacts | {StorageStatus(result.Artifacts.Success, result.Artifacts.FailedDeletes, result.Artifacts.MatchedArtifacts, result.Artifacts.PlannedDeletes, result.Artifacts.DeletedArtifacts)} | {CountAndBytes(result.Artifacts.PlannedDeletes, result.Artifacts.PlannedDeleteBytes)} | {CountAndBytes(result.Artifacts.DeletedArtifacts, result.Artifacts.DeletedBytes)} | {result.Artifacts.FailedDeletes} | - | - |"); } if (result.Caches is not null) { - rows.Add($"| Caches | {Status(result.Caches.Success, result.Caches.FailedDeletes)} | {CountAndBytes(result.Caches.PlannedDeletes, result.Caches.PlannedDeleteBytes)} | {CountAndBytes(result.Caches.DeletedCaches, result.Caches.DeletedBytes)} | {result.Caches.FailedDeletes} | {CacheUsage(result.Caches.UsageBefore)} | {CacheUsage(result.Caches.UsageAfter)} |"); + rows.Add($"| Caches | {StorageStatus(result.Caches.Success, result.Caches.FailedDeletes, result.Caches.MatchedCaches, result.Caches.PlannedDeletes, result.Caches.DeletedCaches)} | {CountAndBytes(result.Caches.PlannedDeletes, result.Caches.PlannedDeleteBytes)} | {CountAndBytes(result.Caches.DeletedCaches, result.Caches.DeletedBytes)} | {result.Caches.FailedDeletes} | {CacheUsage(result.Caches.UsageBefore)} | {CacheUsage(result.Caches.UsageAfter)} |"); } if (result.Runner is not null) { - rows.Add($"| Runner | {Status(result.Runner.Success, result.Runner.Success ? 0 : 1)} | - | - | {(result.Runner.Success ? "0" : "1")} | {FormatGiB(result.Runner.FreeBytesBefore)} | {FormatGiB(result.Runner.FreeBytesAfter)} |"); + rows.Add($"| Runner | {(result.Runner.Success ? "ok" : "failed")} | - | - | {(result.Runner.Success ? "0" : "1")} | {FormatGiB(result.Runner.FreeBytesBefore)} | {FormatGiB(result.Runner.FreeBytesAfter)} |"); } if (rows.Count == 0) @@ -152,6 +153,32 @@ private static void AppendStorageSummary(StringBuilder markdown, GitHubHousekeep markdown.AppendLine(row); } + private static void AppendSelectionSummary(StringBuilder markdown, GitHubHousekeepingResult result) + { + var rows = new List(); + + if (result.Artifacts is not null) + { + rows.Add($"| Artifacts | {result.Artifacts.ScannedArtifacts} | {result.Artifacts.MatchedArtifacts} | {result.Artifacts.KeptByRecentWindow} | {result.Artifacts.KeptByAgeThreshold} | {result.Artifacts.PlannedDeletes} | {SelectionNote(result.Artifacts.MatchedArtifacts, result.Artifacts.PlannedDeletes, result.Artifacts.KeptByRecentWindow, result.Artifacts.KeptByAgeThreshold)} |"); + } + + if (result.Caches is not null) + { + rows.Add($"| Caches | {result.Caches.ScannedCaches} | {result.Caches.MatchedCaches} | {result.Caches.KeptByRecentWindow} | {result.Caches.KeptByAgeThreshold} | {result.Caches.PlannedDeletes} | {SelectionNote(result.Caches.MatchedCaches, result.Caches.PlannedDeletes, result.Caches.KeptByRecentWindow, result.Caches.KeptByAgeThreshold)} |"); + } + + if (rows.Count == 0) + return; + + markdown.AppendLine(); + markdown.AppendLine("## Selection Breakdown"); + markdown.AppendLine(); + markdown.AppendLine("| Section | Scanned | Matched | Kept recent | Kept age | Eligible | Note |"); + markdown.AppendLine("| --- | ---: | ---: | ---: | ---: | ---: | --- |"); + foreach (var row in rows) + markdown.AppendLine(row); + } + private static void AppendArtifactDetails(StringBuilder markdown, GitHubArtifactCleanupResult? result) { if (result is null) @@ -253,8 +280,31 @@ private static string CountAndBytes(int count, long bytes) private static string CacheUsage(GitHubActionsCacheUsage? usage) => usage is null ? "-" : $"{usage.ActiveCachesCount} caches / {FormatGiB(usage.ActiveCachesSizeInBytes)}"; - private static string Status(bool success, int failed) - => !success ? "failed" : failed > 0 ? "warnings" : "ok"; + private static string StorageStatus(bool success, int failed, int matched, int eligible, int deleted) + { + if (!success) + return "failed"; + if (failed > 0) + return "warnings"; + if (deleted > 0) + return "cleaned"; + if (eligible > 0) + return "eligible"; + if (matched > 0) + return "nothing eligible"; + return "no matches"; + } + + private static string SelectionNote(int matched, int eligible, int keptRecent, int keptAge) + { + if (matched == 0) + return "nothing matched the current filters"; + if (eligible > 0) + return "matched items are eligible for cleanup"; + if (keptRecent > 0 || keptAge > 0) + return "all matched items were retained by current policy"; + return "nothing eligible"; + } private static string DeleteState(int? statusCode, string? error) { From 79fb363beaf71d16a86b801c6dedc7f890a2bff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 14:00:38 +0100 Subject: [PATCH 09/13] Harden housekeeping composite action --- .github/actions/github-housekeeping/README.md | 1 + .../actions/github-housekeeping/action.yml | 19 ++++++++++++++++--- .../GitHubHousekeepingActionTests.cs | 4 +++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/actions/github-housekeeping/README.md b/.github/actions/github-housekeeping/README.md index 5a967270..bb6f713c 100644 --- a/.github/actions/github-housekeeping/README.md +++ b/.github/actions/github-housekeeping/README.md @@ -79,6 +79,7 @@ jobs: - Cache and artifact deletion need `actions: write`. - Set `apply: "false"` to preview without deleting anything. +- A dry-run can still report large cache or artifact totals with `0 eligible` deletes when current keep/latest and age rules retain everything; the Markdown summary explains that breakdown. - Hosted-runner repos should usually keep `runner.enabled` set to `false` in config. - The public reusable workflow entrypoint is `powerforge-github-housekeeping.yml`. - The composite action exposes `report-path` and `summary-path` outputs for callers that want to publish the generated reports elsewhere. diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml index 72fb39db..554c6ba2 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -53,11 +53,24 @@ runs: id: housekeeping shell: pwsh run: | + $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../../..")).Path $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" - $args = @('run', '--project', $project, '-c', 'Release', '--no-build', '--framework', 'net10.0', '--', 'github', 'housekeeping', '--config', $env:INPUT_CONFIG_PATH) - if ($env:INPUT_APPLY -eq 'true') { $args += '--apply' } else { $args += '--dry-run' } - & dotnet @args + $configPath = $env:INPUT_CONFIG_PATH + if ([string]::IsNullOrWhiteSpace($configPath)) { + throw "Housekeeping config path is required." + } + + if (-not (Test-Path -LiteralPath $configPath)) { + throw "Housekeeping config not found: $configPath" + } + + $dotnetArgs = @('run', '--project', $project, '-c', 'Release', '--no-build', '--framework', 'net10.0', '--', 'github', 'housekeeping', '--config', $configPath) + if ($env:INPUT_APPLY -eq 'true') { $dotnetArgs += '--apply' } else { $dotnetArgs += '--dry-run' } + & dotnet @dotnetArgs + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: 1 diff --git a/PowerForge.Tests/GitHubHousekeepingActionTests.cs b/PowerForge.Tests/GitHubHousekeepingActionTests.cs index 588f1ebd..eafc5148 100644 --- a/PowerForge.Tests/GitHubHousekeepingActionTests.cs +++ b/PowerForge.Tests/GitHubHousekeepingActionTests.cs @@ -27,7 +27,9 @@ public void CompositeAction_AssetPaths_ShouldResolveFromActionDirectory() Assert.Contains("../../..", actionYaml, StringComparison.Ordinal); Assert.Contains("PowerForge GitHub Housekeeping", actionYaml, StringComparison.Ordinal); Assert.Contains("POWERFORGE_GITHUB_HOUSEKEEPING_REPORT_PATH", actionYaml, StringComparison.Ordinal); - Assert.DoesNotContain("Invoke-GitHubHousekeeping.ps1", actionYaml, StringComparison.Ordinal); + Assert.DoesNotContain("Invoke-PowerForgeHousekeeping.ps1", actionYaml, StringComparison.Ordinal); + Assert.Contains("$dotnetArgs", actionYaml, StringComparison.Ordinal); + Assert.Contains("Housekeeping config not found", actionYaml, StringComparison.Ordinal); } private static string FindRepoRoot() From 79c9e7ddeb524a4a2b921a3ebd39d4c2ab9dc0e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 14:08:18 +0100 Subject: [PATCH 10/13] Add reusable runner housekeeping workflow --- .github/actions/github-housekeeping/README.md | 12 +++ .../powerforge-github-runner-housekeeping.yml | 80 +++++++++++++++++++ .../GitHubRunnerHousekeepingWorkflowTests.cs | 35 ++++++++ 3 files changed, 127 insertions(+) create mode 100644 .github/workflows/powerforge-github-runner-housekeeping.yml create mode 100644 PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs diff --git a/.github/actions/github-housekeeping/README.md b/.github/actions/github-housekeeping/README.md index bb6f713c..3323477f 100644 --- a/.github/actions/github-housekeeping/README.md +++ b/.github/actions/github-housekeeping/README.md @@ -31,6 +31,18 @@ For immutable pinning, use the same PSPublishModule commit SHA for both the reus The reusable workflow uploads the generated JSON and Markdown reports as an artifact by default. +For self-hosted runner disk cleanup, use the dedicated reusable workflow entrypoint: + +```yaml +jobs: + housekeeping: + uses: EvotecIT/PSPublishModule/.github/workflows/powerforge-github-runner-housekeeping.yml@main + with: + config-path: ./.powerforge/runner-housekeeping.json + powerforge-ref: main + runner-labels: '["self-hosted","ubuntu"]' +``` + Minimal config: ```json diff --git a/.github/workflows/powerforge-github-runner-housekeeping.yml b/.github/workflows/powerforge-github-runner-housekeeping.yml new file mode 100644 index 00000000..810e4bdc --- /dev/null +++ b/.github/workflows/powerforge-github-runner-housekeeping.yml @@ -0,0 +1,80 @@ +name: PowerForge GitHub Runner Housekeeping + +on: + workflow_call: + inputs: + config-path: + description: Path to the runner-housekeeping config file in the caller repository. + required: false + default: ".powerforge/runner-housekeeping.json" + type: string + apply: + description: Whether the run should apply cleanup steps. + required: false + default: true + type: boolean + powerforge-ref: + description: PSPublishModule ref used to resolve the shared housekeeping action. + required: false + default: "main" + type: string + runner-labels: + description: JSON array of runner labels used for the cleanup job. + required: false + default: "[\"self-hosted\",\"ubuntu\"]" + type: string + report-path: + description: Optional JSON report path relative to the caller workspace. + required: false + default: ".powerforge/_reports/runner-housekeeping.json" + type: string + summary-path: + description: Optional Markdown summary path relative to the caller workspace. + required: false + default: ".powerforge/_reports/runner-housekeeping.md" + type: string + report-artifact-name: + description: Artifact name used when uploading runner housekeeping reports. + required: false + default: "powerforge-runner-housekeeping-reports" + type: string + secrets: + github-token: + required: false + +permissions: + contents: read + +jobs: + housekeeping: + runs-on: ${{ fromJson(inputs['runner-labels']) }} + 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 runner housekeeping + id: 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 }} + report-path: ${{ inputs['report-path'] }} + summary-path: ${{ inputs['summary-path'] }} + + - name: Upload runner housekeeping reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs['report-artifact-name'] }} + path: | + ${{ steps.housekeeping.outputs.report-path }} + ${{ steps.housekeeping.outputs.summary-path }} + if-no-files-found: ignore diff --git a/PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs b/PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs new file mode 100644 index 00000000..727ac0c4 --- /dev/null +++ b/PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs @@ -0,0 +1,35 @@ +namespace PowerForge.Tests; + +public sealed class GitHubRunnerHousekeepingWorkflowTests +{ + [Fact] + public void ReusableWorkflow_ShouldUseSharedCompositeAction() + { + var repoRoot = FindRepoRoot(); + var workflowPath = Path.Combine(repoRoot, ".github", "workflows", "powerforge-github-runner-housekeeping.yml"); + + Assert.True(File.Exists(workflowPath), $"Runner housekeeping workflow not found: {workflowPath}"); + + var workflowYaml = File.ReadAllText(workflowPath); + Assert.Contains("PowerForge GitHub Runner Housekeeping", workflowYaml, StringComparison.Ordinal); + Assert.Contains("runner-labels", workflowYaml, StringComparison.Ordinal); + Assert.Contains("fromJson(inputs['runner-labels'])", workflowYaml, StringComparison.Ordinal); + Assert.Contains("./.powerforge/pspublishmodule/.github/actions/github-housekeeping", workflowYaml, StringComparison.Ordinal); + Assert.Contains(".powerforge/runner-housekeeping.json", workflowYaml, StringComparison.Ordinal); + Assert.Contains("report-artifact-name", workflowYaml, StringComparison.Ordinal); + } + + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var i = 0; i < 12 && current is not null; i++) + { + var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); + if (File.Exists(marker)) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("Unable to locate repository root for GitHub runner housekeeping workflow tests."); + } +} From 31b76ee806028f163d13a613ad0c2be5169745d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 14:11:45 +0100 Subject: [PATCH 11/13] Clarify housekeeping apply ownership --- .github/actions/github-housekeeping/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/github-housekeeping/README.md b/.github/actions/github-housekeeping/README.md index 3323477f..fbc37064 100644 --- a/.github/actions/github-housekeeping/README.md +++ b/.github/actions/github-housekeeping/README.md @@ -50,7 +50,6 @@ Minimal config: "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/github.housekeeping.schema.json", "repository": "EvotecIT/YourRepo", "tokenEnvName": "GITHUB_TOKEN", - "dryRun": false, "artifacts": { "enabled": true, "keepLatestPerName": 10, @@ -91,6 +90,7 @@ jobs: - Cache and artifact deletion need `actions: write`. - Set `apply: "false"` to preview without deleting anything. +- Prefer letting the workflow decide apply vs dry-run; omit `dryRun` from checked-in repo config unless you have a non-workflow caller that truly needs a local default. - A dry-run can still report large cache or artifact totals with `0 eligible` deletes when current keep/latest and age rules retain everything; the Markdown summary explains that breakdown. - Hosted-runner repos should usually keep `runner.enabled` set to `false` in config. - The public reusable workflow entrypoint is `powerforge-github-housekeeping.yml`. From aeafc6ee579a474a14e9f6542ffbd1a2eb81e615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 14:13:31 +0100 Subject: [PATCH 12/13] Polish housekeeping reporting flow --- .github/actions/github-housekeeping/action.yml | 1 + .../Program.Command.GitHub.HousekeepingOutputs.cs | 7 ++++--- PowerForge/Services/GitHubHousekeepingReportService.cs | 10 +++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml index 554c6ba2..bce35b11 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -65,6 +65,7 @@ runs: throw "Housekeeping config not found: $configPath" } + # Keep this framework in sync with the runnable TFM in PowerForge.Cli.csproj. $dotnetArgs = @('run', '--project', $project, '-c', 'Release', '--no-build', '--framework', 'net10.0', '--', 'github', 'housekeeping', '--config', $configPath) if ($env:INPUT_APPLY -eq 'true') { $dotnetArgs += '--apply' } else { $dotnetArgs += '--dry-run' } & dotnet @dotnetArgs diff --git a/PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs b/PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs index 4d02768c..67ff03bc 100644 --- a/PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs +++ b/PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs @@ -28,16 +28,17 @@ private static void WriteGitHubHousekeepingOutputs( GitHubHousekeepingReport report, GitHubHousekeepingOutputOptions options) { - var markdown = reports.BuildMarkdown(report); + string? markdown = null; + string GetMarkdown() => markdown ??= reports.BuildMarkdown(report); if (!string.IsNullOrWhiteSpace(options.JsonReportPath)) reports.WriteJsonReport(options.JsonReportPath, report); if (!string.IsNullOrWhiteSpace(options.MarkdownReportPath)) - reports.WriteMarkdownReport(options.MarkdownReportPath, report); + reports.WriteMarkdownReport(options.MarkdownReportPath, GetMarkdown()); if (!string.IsNullOrWhiteSpace(options.StepSummaryPath)) - AppendUtf8(options.StepSummaryPath, markdown + Environment.NewLine); + AppendUtf8(options.StepSummaryPath, GetMarkdown() + Environment.NewLine); if (!string.IsNullOrWhiteSpace(options.GitHubOutputPath)) { diff --git a/PowerForge/Services/GitHubHousekeepingReportService.cs b/PowerForge/Services/GitHubHousekeepingReportService.cs index 657998ba..4d163aaa 100644 --- a/PowerForge/Services/GitHubHousekeepingReportService.cs +++ b/PowerForge/Services/GitHubHousekeepingReportService.cs @@ -122,6 +122,14 @@ public void WriteJsonReport(string path, GitHubHousekeepingReport report) public void WriteMarkdownReport(string path, GitHubHousekeepingReport report) => WriteUtf8(path, BuildMarkdown(report)); + /// + /// Writes pre-rendered Markdown report content to disk using UTF-8 without BOM. + /// + /// Destination file path. + /// Markdown content to persist. + public void WriteMarkdownReport(string path, string markdown) + => WriteUtf8(path, markdown); + private static void AppendStorageSummary(StringBuilder markdown, GitHubHousekeepingResult result) { var rows = new List(); @@ -323,7 +331,7 @@ private static string EscapeCell(string? value) if (string.IsNullOrWhiteSpace(value)) return "-"; - var text = value ?? string.Empty; + var text = value; return text.Replace("|", "\\|") .Replace("\r", " ") .Replace("\n", "
"); From 8cd12880141b7785e9a2bd969305154fc241aabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 26 Mar 2026 15:31:37 +0100 Subject: [PATCH 13/13] Fix housekeeping runner overrides and net472 build --- .github/actions/github-housekeeping/action.yml | 9 +++++++++ .../workflows/powerforge-github-runner-housekeeping.yml | 7 +++++++ PowerForge.Cli/Program.Command.GitHub.cs | 5 ++++- PowerForge.Tests/GitHubHousekeepingActionTests.cs | 2 ++ .../GitHubRunnerHousekeepingWorkflowTests.cs | 2 ++ PowerForge/Services/GitHubHousekeepingReportService.cs | 2 +- 6 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml index bce35b11..dc55b610 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -14,6 +14,10 @@ inputs: description: Optional token override for remote GitHub cleanup. required: false default: "" + runner-min-free-gb: + description: Optional runner free-disk threshold override in GiB. + required: false + default: "" report-path: description: Optional JSON report output path relative to the workspace. required: false @@ -67,6 +71,10 @@ runs: # Keep this framework in sync with the runnable TFM in PowerForge.Cli.csproj. $dotnetArgs = @('run', '--project', $project, '-c', 'Release', '--no-build', '--framework', 'net10.0', '--', 'github', 'housekeeping', '--config', $configPath) + if (-not [string]::IsNullOrWhiteSpace($env:INPUT_RUNNER_MIN_FREE_GB)) { + $dotnetArgs += '--runner-min-free-gb' + $dotnetArgs += $env:INPUT_RUNNER_MIN_FREE_GB + } if ($env:INPUT_APPLY -eq 'true') { $dotnetArgs += '--apply' } else { $dotnetArgs += '--dry-run' } & dotnet @dotnetArgs if ($LASTEXITCODE -ne 0) { @@ -77,6 +85,7 @@ runs: DOTNET_CLI_TELEMETRY_OPTOUT: 1 INPUT_CONFIG_PATH: ${{ inputs['config-path'] }} INPUT_APPLY: ${{ inputs.apply }} + INPUT_RUNNER_MIN_FREE_GB: ${{ inputs['runner-min-free-gb'] }} GITHUB_TOKEN: ${{ inputs['github-token'] != '' && inputs['github-token'] || github.token }} POWERFORGE_GITHUB_HOUSEKEEPING_REPORT_PATH: ${{ inputs['report-path'] }} POWERFORGE_GITHUB_HOUSEKEEPING_SUMMARY_PATH: ${{ inputs['summary-path'] }} diff --git a/.github/workflows/powerforge-github-runner-housekeeping.yml b/.github/workflows/powerforge-github-runner-housekeeping.yml index 810e4bdc..4fedd04a 100644 --- a/.github/workflows/powerforge-github-runner-housekeeping.yml +++ b/.github/workflows/powerforge-github-runner-housekeeping.yml @@ -13,6 +13,11 @@ on: required: false default: true type: boolean + runner-min-free-gb: + description: Optional runner free-disk threshold override in GiB. + required: false + default: "" + type: string powerforge-ref: description: PSPublishModule ref used to resolve the shared housekeeping action. required: false @@ -44,6 +49,7 @@ on: permissions: contents: read + actions: write jobs: housekeeping: @@ -66,6 +72,7 @@ jobs: config-path: ${{ inputs['config-path'] }} apply: ${{ inputs.apply && 'true' || 'false' }} github-token: ${{ secrets['github-token'] != '' && secrets['github-token'] || github.token }} + runner-min-free-gb: ${{ inputs['runner-min-free-gb'] }} report-path: ${{ inputs['report-path'] }} summary-path: ${{ inputs['summary-path'] }} diff --git a/PowerForge.Cli/Program.Command.GitHub.cs b/PowerForge.Cli/Program.Command.GitHub.cs index 143b7f12..ce789ceb 100644 --- a/PowerForge.Cli/Program.Command.GitHub.cs +++ b/PowerForge.Cli/Program.Command.GitHub.cs @@ -9,7 +9,7 @@ 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 GitHubHousekeepingUsage = "Usage: powerforge github housekeeping [--config ] [--repo ] [--api-base-url ] [--token-env ] [--token ] [--runner-min-free-gb ] [--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) @@ -566,6 +566,9 @@ private static GitHubHousekeepingSpec ParseGitHubHousekeepingArgs(string[] argv) case "--api-url": spec.ApiBaseUrl = ++i < argv.Length ? argv[i] : string.Empty; break; + case "--runner-min-free-gb": + spec.Runner.MinFreeGb = ParseOptionalPositiveInt(argv, ref i, "--runner-min-free-gb"); + break; case "--dry-run": spec.DryRun = true; break; diff --git a/PowerForge.Tests/GitHubHousekeepingActionTests.cs b/PowerForge.Tests/GitHubHousekeepingActionTests.cs index eafc5148..7940130e 100644 --- a/PowerForge.Tests/GitHubHousekeepingActionTests.cs +++ b/PowerForge.Tests/GitHubHousekeepingActionTests.cs @@ -24,9 +24,11 @@ public void CompositeAction_AssetPaths_ShouldResolveFromActionDirectory() Assert.Contains("../../../global.json", actionYaml, StringComparison.Ordinal); Assert.Contains("report-path", actionYaml, StringComparison.Ordinal); Assert.Contains("summary-path", actionYaml, StringComparison.Ordinal); + Assert.Contains("runner-min-free-gb", actionYaml, StringComparison.Ordinal); Assert.Contains("../../..", actionYaml, StringComparison.Ordinal); Assert.Contains("PowerForge GitHub Housekeeping", actionYaml, StringComparison.Ordinal); Assert.Contains("POWERFORGE_GITHUB_HOUSEKEEPING_REPORT_PATH", actionYaml, StringComparison.Ordinal); + Assert.Contains("--runner-min-free-gb", actionYaml, StringComparison.Ordinal); Assert.DoesNotContain("Invoke-PowerForgeHousekeeping.ps1", actionYaml, StringComparison.Ordinal); Assert.Contains("$dotnetArgs", actionYaml, StringComparison.Ordinal); Assert.Contains("Housekeeping config not found", actionYaml, StringComparison.Ordinal); diff --git a/PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs b/PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs index 727ac0c4..f71fc20a 100644 --- a/PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs +++ b/PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs @@ -16,7 +16,9 @@ public void ReusableWorkflow_ShouldUseSharedCompositeAction() Assert.Contains("fromJson(inputs['runner-labels'])", workflowYaml, StringComparison.Ordinal); Assert.Contains("./.powerforge/pspublishmodule/.github/actions/github-housekeeping", workflowYaml, StringComparison.Ordinal); Assert.Contains(".powerforge/runner-housekeeping.json", workflowYaml, StringComparison.Ordinal); + Assert.Contains("runner-min-free-gb", workflowYaml, StringComparison.Ordinal); Assert.Contains("report-artifact-name", workflowYaml, StringComparison.Ordinal); + Assert.Contains("actions: write", workflowYaml, StringComparison.Ordinal); } private static string FindRepoRoot() diff --git a/PowerForge/Services/GitHubHousekeepingReportService.cs b/PowerForge/Services/GitHubHousekeepingReportService.cs index 4d163aaa..e47d4ca7 100644 --- a/PowerForge/Services/GitHubHousekeepingReportService.cs +++ b/PowerForge/Services/GitHubHousekeepingReportService.cs @@ -331,7 +331,7 @@ private static string EscapeCell(string? value) if (string.IsNullOrWhiteSpace(value)) return "-"; - var text = value; + var text = value!; return text.Replace("|", "\\|") .Replace("\r", " ") .Replace("\n", "
");