diff --git a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 deleted file mode 100644 index 5a6ace24..00000000 --- a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 +++ /dev/null @@ -1,159 +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 Write-MarkdownSummary { - param([string[]] $Lines) - - if ([string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { - return - } - - Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ($Lines -join [Environment]::NewLine) -} - -function Resolve-ConfigPath { - $configPath = $env:INPUT_CONFIG_PATH - if ([string]::IsNullOrWhiteSpace($configPath)) { - $configPath = '.powerforge/github-housekeeping.json' - } - - if ([System.IO.Path]::IsPathRooted($configPath)) { - return [System.IO.Path]::GetFullPath($configPath) - } - - if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { - throw 'GITHUB_WORKSPACE is not set.' - } - - return [System.IO.Path]::GetFullPath((Join-Path $env:GITHUB_WORKSPACE $configPath)) -} - -function Write-HousekeepingSummary { - param([pscustomobject] $Envelope) - - if (-not $Envelope.result) { - return - } - - $result = $Envelope.result - $lines = @( - "### GitHub housekeeping", - "", - "- Mode: $(if ($result.dryRun) { 'dry-run' } else { 'apply' })", - "- Requested sections: $((@($result.requestedSections) -join ', '))", - "- Completed sections: $((@($result.completedSections) -join ', '))", - "- Failed sections: $((@($result.failedSections) -join ', '))", - "- Success: $(if ($Envelope.success) { 'yes' } else { 'no' })" - ) - - if ($result.message) { - $lines += "- Message: $($result.message)" - } - - if ($result.caches) { - $lines += '' - $lines += '#### Caches' - if ($result.caches.usageBefore) { - $lines += "- Usage before: $($result.caches.usageBefore.activeCachesCount) caches, $(Format-GiB ([long]$result.caches.usageBefore.activeCachesSizeInBytes))" - } - if ($result.caches.usageAfter) { - $lines += "- Usage after: $($result.caches.usageAfter.activeCachesCount) caches, $(Format-GiB ([long]$result.caches.usageAfter.activeCachesSizeInBytes))" - } - $lines += "- Planned deletes: $($result.caches.plannedDeletes) ($(Format-GiB ([long]$result.caches.plannedDeleteBytes)))" - $lines += "- Deleted: $($result.caches.deletedCaches) ($(Format-GiB ([long]$result.caches.deletedBytes)))" - $lines += "- Failed deletes: $($result.caches.failedDeletes)" - } - - if ($result.artifacts) { - $lines += '' - $lines += '#### Artifacts' - $lines += "- Planned deletes: $($result.artifacts.plannedDeletes) ($(Format-GiB ([long]$result.artifacts.plannedDeleteBytes)))" - $lines += "- Deleted: $($result.artifacts.deletedArtifacts) ($(Format-GiB ([long]$result.artifacts.deletedBytes)))" - $lines += "- Failed deletes: $($result.artifacts.failedDeletes)" - } - - if ($result.runner) { - $lines += '' - $lines += '#### Runner' - $lines += "- Free before: $(Format-GiB ([long]$result.runner.freeBytesBefore))" - $lines += "- Free after: $(Format-GiB ([long]$result.runner.freeBytesAfter))" - $lines += "- Aggressive cleanup: $(if ($result.runner.aggressiveApplied) { 'yes' } else { 'no' })" - } - - Write-Host ("GitHub housekeeping: requested={0}; completed={1}; failed={2}" -f ` - (@($result.requestedSections) -join ','), ` - (@($result.completedSections) -join ','), ` - (@($result.failedSections) -join ',')) - - Write-MarkdownSummary -Lines ($lines + '') -} - -$configPath = Resolve-ConfigPath -if (-not (Test-Path -LiteralPath $configPath)) { - throw "Housekeeping config not found: $configPath" -} - -$arguments = [System.Collections.Generic.List[string]]::new() -$arguments.AddRange(@( - 'run', '--project', $project, '-c', 'Release', '--no-build', '--', - 'github', 'housekeeping', - '--config', $configPath -)) - -if ($env:INPUT_APPLY -eq 'true') { - $null = $arguments.Add('--apply') -} else { - $null = $arguments.Add('--dry-run') -} - -if (-not [string]::IsNullOrWhiteSpace($env:POWERFORGE_GITHUB_TOKEN)) { - $null = $arguments.Add('--token-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 - -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 fb82c154..fbc37064 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,12 +20,54 @@ 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 secrets: inherit ``` +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. + +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 +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/github.housekeeping.schema.json", + "repository": "EvotecIT/YourRepo", + "tokenEnvName": "GITHUB_TOKEN", + "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 @@ -47,4 +90,8 @@ 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`. +- 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 5ba2daae..dc55b610 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -14,6 +14,26 @@ 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 + 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 @@ -21,12 +41,12 @@ 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 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: @@ -34,11 +54,38 @@ runs: DOTNET_CLI_TELEMETRY_OPTOUT: 1 - name: Run GitHub housekeeping + id: housekeeping shell: pwsh - run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 + run: | + $ErrorActionPreference = 'Stop' + $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../../..")).Path + $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" + $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" + } + + # 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) { + exit $LASTEXITCODE + } env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: 1 INPUT_CONFIG_PATH: ${{ inputs['config-path'] }} INPUT_APPLY: ${{ inputs.apply }} - POWERFORGE_GITHUB_TOKEN: ${{ inputs['github-token'] != '' && inputs['github-token'] || github.token }} + 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/github-housekeeping.yml b/.github/workflows/github-housekeeping.yml index 0f1b16be..48516fa6 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 @@ -20,10 +20,10 @@ 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' }} + 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/.github/workflows/reusable-github-housekeeping.yml b/.github/workflows/powerforge-github-housekeeping.yml similarity index 54% rename from .github/workflows/reusable-github-housekeeping.yml rename to .github/workflows/powerforge-github-housekeeping.yml index d23217c7..84c9f10e 100644 --- a/.github/workflows/reusable-github-housekeeping.yml +++ b/.github/workflows/powerforge-github-housekeeping.yml @@ -1,4 +1,4 @@ -name: Reusable GitHub Housekeeping +name: PowerForge GitHub Housekeeping 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 @@ -41,8 +56,21 @@ jobs: 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/powerforge-github-runner-housekeeping.yml b/.github/workflows/powerforge-github-runner-housekeeping.yml new file mode 100644 index 00000000..4fedd04a --- /dev/null +++ b/.github/workflows/powerforge-github-runner-housekeeping.yml @@ -0,0 +1,87 @@ +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 + 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 + 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 + actions: write + +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 }} + runner-min-free-gb: ${{ inputs['runner-min-free-gb'] }} + 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.Cli/Program.Command.GitHub.HousekeepingOutputs.cs b/PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs new file mode 100644 index 00000000..67ff03bc --- /dev/null +++ b/PowerForge.Cli/Program.Command.GitHub.HousekeepingOutputs.cs @@ -0,0 +1,61 @@ +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) + { + 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, GetMarkdown()); + + if (!string.IsNullOrWhiteSpace(options.StepSummaryPath)) + AppendUtf8(options.StepSummaryPath, GetMarkdown() + 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..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) @@ -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); } } @@ -561,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 new file mode 100644 index 00000000..7940130e --- /dev/null +++ b/PowerForge.Tests/GitHubHousekeepingActionTests.cs @@ -0,0 +1,50 @@ +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"); + var actionYamlPath = Path.Combine(actionRoot, "action.yml"); + var publicWorkflowPath = Path.Combine(repoRoot, ".github", "workflows", "powerforge-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(publicWorkflowPath), $"Public reusable workflow not found: {publicWorkflowPath}"); + + 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); + 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); + } + + 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."); + } +} diff --git a/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs b/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs new file mode 100644 index 00000000..7bce695b --- /dev/null +++ b/PowerForge.Tests/GitHubHousekeepingReportServiceTests.cs @@ -0,0 +1,72 @@ +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, + ScannedArtifacts = 3, + MatchedArtifacts = 1, + KeptByRecentWindow = 0, + KeptByAgeThreshold = 0, + PlannedDeletes = 1, + PlannedDeleteBytes = 1024, + Planned = + [ + new GitHubArtifactCleanupItem + { + Name = "github-pages", + SizeInBytes = 1024, + Reason = "older duplicate" + } + ] + }, + Caches = new GitHubActionsCacheCleanupResult + { + Success = true, + ScannedCaches = 29, + MatchedCaches = 29, + KeptByRecentWindow = 25, + KeptByAgeThreshold = 4, + UsageBefore = new GitHubActionsCacheUsage + { + ActiveCachesCount = 29, + ActiveCachesSizeInBytes = 10053309332 + } + } + }); + + var markdown = service.BuildMarkdown(report); + + 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("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] + 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.Tests/GitHubRunnerHousekeepingWorkflowTests.cs b/PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs new file mode 100644 index 00000000..f71fc20a --- /dev/null +++ b/PowerForge.Tests/GitHubRunnerHousekeepingWorkflowTests.cs @@ -0,0 +1,37 @@ +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("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() + { + 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."); + } +} 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..e47d4ca7 --- /dev/null +++ b/PowerForge/Services/GitHubHousekeepingReportService.cs @@ -0,0 +1,357 @@ +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) + { + if (result is null) + throw new ArgumentNullException(nameof(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) + { + if (report is null) + throw new ArgumentNullException(nameof(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) + { + if (report is null) + throw new ArgumentNullException(nameof(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); + AppendSelectionSummary(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)); + + /// + /// 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(); + + if (result.Artifacts is not null) + { + 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 | {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 | {(result.Runner.Success ? "ok" : "failed")} | - | - | {(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 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) + 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 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) + { + 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 "-"; + + var text = value!; + return text.Replace("|", "\\|") + .Replace("\r", " ") + .Replace("\n", "
"); + } + + 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