From f4c27127753cc1740b62d4473da8767d6e8c8d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 12 Mar 2026 09:47:37 +0100 Subject: [PATCH 1/7] Add reusable GitHub housekeeping workflows --- .github/actions/github-housekeeping/README.md | 52 ++ .../actions/github-housekeeping/action.yml | 195 ++++++ .github/workflows/github-housekeeping.yml | 43 ++ PowerForge.Cli/PowerForgeCliJsonContext.cs | 2 + PowerForge.Cli/Program.Command.GitHub.cs | 623 ++++++++++++++---- PowerForge.Cli/Program.Helpers.cs | 7 + .../GitHubActionsCacheCleanupServiceTests.cs | 219 ++++++ .../RunnerHousekeepingServiceTests.cs | 115 ++++ .../Models/GitHubActionsCacheCleanup.cs | 254 +++++++ PowerForge/Models/RunnerHousekeeping.cs | 242 +++++++ .../GitHubActionsCacheCleanupService.cs | 560 ++++++++++++++++ .../Services/RunnerHousekeepingService.cs | 569 ++++++++++++++++ README.MD | 26 + 13 files changed, 2777 insertions(+), 130 deletions(-) create mode 100644 .github/actions/github-housekeeping/README.md create mode 100644 .github/actions/github-housekeeping/action.yml create mode 100644 .github/workflows/github-housekeeping.yml create mode 100644 PowerForge.Tests/GitHubActionsCacheCleanupServiceTests.cs create mode 100644 PowerForge.Tests/RunnerHousekeepingServiceTests.cs create mode 100644 PowerForge/Models/GitHubActionsCacheCleanup.cs create mode 100644 PowerForge/Models/RunnerHousekeeping.cs create mode 100644 PowerForge/Services/GitHubActionsCacheCleanupService.cs create mode 100644 PowerForge/Services/RunnerHousekeepingService.cs diff --git a/.github/actions/github-housekeeping/README.md b/.github/actions/github-housekeeping/README.md new file mode 100644 index 00000000..769d7459 --- /dev/null +++ b/.github/actions/github-housekeeping/README.md @@ -0,0 +1,52 @@ +# PowerForge GitHub Housekeeping + +Reusable composite action that wraps the new C# housekeeping commands from `PowerForge.Cli`. + +## What it does + +- Cleans runner working sets (`powerforge github runner cleanup`) +- Prunes GitHub Actions caches (`powerforge github caches prune`) +- Prunes GitHub Actions artifacts (`powerforge github artifacts prune`) +- Builds the CLI from this repository, so other repos can consume the action with one `uses:` step + +## Minimal usage + +```yaml +permissions: + contents: read + actions: write + +jobs: + housekeeping: + runs-on: ubuntu-latest + steps: + - uses: EvotecIT/PSPublishModule/.github/actions/github-housekeeping@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Typical self-hosted usage + +```yaml +permissions: + contents: read + actions: write + +jobs: + housekeeping: + runs-on: [self-hosted, ubuntu] + steps: + - uses: EvotecIT/PSPublishModule/.github/actions/github-housekeeping@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + min-free-gb: "20" + cache-max-age-days: "14" + cache-max-delete: "200" +``` + +## Notes + +- Cache deletion needs `actions: write`. +- Set `apply: "false"` to preview without deleting anything. +- Set `cleanup-runner: "false"` if you only want remote GitHub storage cleanup. +- Set `cleanup-caches: "false"` or `cleanup-artifacts: "false"` to narrow what gets pruned. diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml new file mode 100644 index 00000000..89d18443 --- /dev/null +++ b/.github/actions/github-housekeeping/action.yml @@ -0,0 +1,195 @@ +name: PowerForge GitHub Housekeeping +description: Clean GitHub Actions caches and runner working sets using PowerForge.Cli. + +inputs: + apply: + description: When true, apply deletions. When false, run in dry-run mode. + required: false + default: "true" + cleanup-runner: + description: When true, clean local runner working sets. + required: false + default: "true" + cleanup-artifacts: + description: When true, prune GitHub Actions artifacts for the repository. + required: false + default: "true" + cleanup-caches: + description: When true, prune GitHub Actions caches for the repository. + required: false + default: "true" + repo: + description: Repository in owner/repo format. Defaults to the current repository. + required: false + default: "" + github-token: + description: Token used for GitHub cache cleanup. Defaults to github.token. + required: false + default: "" + min-free-gb: + description: Minimum free disk required after runner cleanup. + required: false + default: "20" + runner-aggressive-threshold-gb: + description: Free disk threshold below which aggressive runner cleanup is enabled. + required: false + default: "" + allow-sudo: + description: Allow sudo for deleting protected directories on Unix runners. + required: false + default: "false" + cache-keep: + description: Number of newest caches to keep per cache key. + required: false + default: "1" + cache-max-age-days: + description: Minimum cache age before deletion is allowed. + required: false + default: "14" + cache-max-delete: + description: Maximum number of caches to delete in one run. + required: false + default: "200" + cache-key: + description: Optional cache key include patterns (comma-separated). + required: false + default: "" + cache-exclude: + description: Optional cache key exclude patterns (comma-separated). + required: false + default: "" + artifact-keep: + description: Number of newest artifacts to keep per artifact name. + required: false + default: "5" + artifact-max-age-days: + description: Minimum artifact age before deletion is allowed. + required: false + default: "7" + artifact-max-delete: + description: Maximum number of artifacts to delete in one run. + required: false + default: "200" + artifact-name: + description: Optional artifact include patterns (comma-separated). + required: false + default: "" + artifact-exclude: + description: Optional artifact exclude patterns (comma-separated). + required: false + default: "" + +runs: + using: composite + steps: + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: ${{ github.action_path }}/../../global.json + + - name: Build PowerForge CLI + shell: pwsh + run: | + $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path + $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" + dotnet build $project -c Release + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + + - name: Cleanup runner working sets + if: ${{ inputs.cleanup-runner == 'true' }} + shell: pwsh + 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', '--', + 'github', 'runner', 'cleanup' + ) + + if ('${{ inputs.apply }}' -eq 'true') { $args += '--apply' } else { $args += '--dry-run' } + if ('${{ inputs.min-free-gb }}') { $args += @('--min-free-gb', '${{ inputs.min-free-gb }}') } + if ('${{ inputs.runner-aggressive-threshold-gb }}') { $args += @('--aggressive-threshold-gb', '${{ inputs.runner-aggressive-threshold-gb }}') } + if ('${{ inputs.allow-sudo }}' -eq 'true') { $args += '--allow-sudo' } + + dotnet @args + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + + - name: Cleanup GitHub caches + if: ${{ inputs.cleanup-caches == 'true' }} + shell: pwsh + run: | + $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path + $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" + + $repository = '${{ inputs.repo }}' + if ([string]::IsNullOrWhiteSpace($repository)) { $repository = '${{ github.repository }}' } + + $token = $env:POWERFORGE_GITHUB_TOKEN + if ([string]::IsNullOrWhiteSpace($token)) { + Write-Error 'GitHub token is required for cache cleanup.' + exit 1 + } + + $args = @( + 'run', '--project', $project, '-c', 'Release', '--no-build', '--', + 'github', 'caches', 'prune', + '--repo', $repository, + '--token', $token, + '--keep', '${{ inputs.cache-keep }}', + '--max-age-days', '${{ inputs.cache-max-age-days }}', + '--max-delete', '${{ inputs.cache-max-delete }}' + ) + + if ('${{ inputs.apply }}' -eq 'true') { $args += '--apply' } else { $args += '--dry-run' } + if ('${{ inputs.cache-key }}') { $args += @('--key', '${{ inputs.cache-key }}') } + if ('${{ inputs.cache-exclude }}') { $args += @('--exclude', '${{ inputs.cache-exclude }}') } + + dotnet @args + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + POWERFORGE_GITHUB_TOKEN: ${{ inputs.github-token != '' && inputs.github-token || github.token }} + + - name: Cleanup GitHub artifacts + if: ${{ inputs.cleanup-artifacts == 'true' }} + shell: pwsh + run: | + $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path + $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" + + $repository = '${{ inputs.repo }}' + if ([string]::IsNullOrWhiteSpace($repository)) { $repository = '${{ github.repository }}' } + + $token = $env:POWERFORGE_GITHUB_TOKEN + if ([string]::IsNullOrWhiteSpace($token)) { + Write-Error 'GitHub token is required for artifact cleanup.' + exit 1 + } + + $args = @( + 'run', '--project', $project, '-c', 'Release', '--no-build', '--', + 'github', 'artifacts', 'prune', + '--repo', $repository, + '--token', $token, + '--keep', '${{ inputs.artifact-keep }}', + '--max-age-days', '${{ inputs.artifact-max-age-days }}', + '--max-delete', '${{ inputs.artifact-max-delete }}' + ) + + if ('${{ inputs.apply }}' -eq 'true') { $args += '--apply' } else { $args += '--dry-run' } + if ('${{ inputs.artifact-name }}') { $args += @('--name', '${{ inputs.artifact-name }}') } + if ('${{ inputs.artifact-exclude }}') { $args += @('--exclude', '${{ inputs.artifact-exclude }}') } + + dotnet @args + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + POWERFORGE_GITHUB_TOKEN: ${{ inputs.github-token != '' && inputs.github-token || github.token }} diff --git a/.github/workflows/github-housekeeping.yml b/.github/workflows/github-housekeeping.yml new file mode 100644 index 00000000..3dfc4de5 --- /dev/null +++ b/.github/workflows/github-housekeeping.yml @@ -0,0 +1,43 @@ +name: GitHub Housekeeping + +on: + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + inputs: + apply: + description: 'Apply deletions (true/false)' + required: false + default: 'true' + artifact_max_age_days: + description: 'Delete artifacts older than N days' + required: false + default: '7' + cache_max_age_days: + description: 'Delete caches older than N days' + required: false + default: '14' + +permissions: + actions: write + contents: read + +concurrency: + group: github-housekeeping-${{ github.repository }} + cancel-in-progress: false + +jobs: + housekeeping: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Run PowerForge housekeeping + uses: ./.github/actions/github-housekeeping + with: + apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply || 'true' }} + github-token: ${{ secrets.GITHUB_TOKEN }} + cleanup-runner: 'false' + artifact-max-age-days: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_max_age_days || '7' }} + cache-max-age-days: ${{ github.event_name == 'workflow_dispatch' && inputs.cache_max_age_days || '14' }} diff --git a/PowerForge.Cli/PowerForgeCliJsonContext.cs b/PowerForge.Cli/PowerForgeCliJsonContext.cs index 9951a14f..4818cafd 100644 --- a/PowerForge.Cli/PowerForgeCliJsonContext.cs +++ b/PowerForge.Cli/PowerForgeCliJsonContext.cs @@ -34,6 +34,8 @@ namespace PowerForge.Cli; [JsonSerializable(typeof(DotNetPublishFailure))] [JsonSerializable(typeof(DotNetPublishConfigScaffoldResult))] [JsonSerializable(typeof(GitHubArtifactCleanupResult))] +[JsonSerializable(typeof(GitHubActionsCacheCleanupResult))] +[JsonSerializable(typeof(RunnerHousekeepingResult))] [JsonSerializable(typeof(ArtefactBuildResult[]))] [JsonSerializable(typeof(NormalizationResult[]))] [JsonSerializable(typeof(FormatterResult[]))] diff --git a/PowerForge.Cli/Program.Command.GitHub.cs b/PowerForge.Cli/Program.Command.GitHub.cs index e664f0c9..52ebffbb 100644 --- a/PowerForge.Cli/Program.Command.GitHub.cs +++ b/PowerForge.Cli/Program.Command.GitHub.cs @@ -6,124 +6,284 @@ 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 GitHubRunnerCleanupUsage = "Usage: powerforge github runner cleanup [--runner-temp ] [--work-root ] [--runner-root ] [--diag-root ] [--tool-cache ] [--min-free-gb ] [--aggressive-threshold-gb ] [--diag-retention-days ] [--actions-retention-days ] [--tool-cache-retention-days ] [--dry-run|--apply] [--aggressive] [--allow-sudo] [--skip-diagnostics] [--skip-runner-temp] [--skip-actions-cache] [--skip-tool-cache] [--skip-dotnet-cache] [--skip-docker] [--no-docker-volumes] [--output json]"; + private static int CommandGitHub(string[] filteredArgs, CliOptions cli, ILogger logger) { var argv = filteredArgs.Skip(1).ToArray(); - if (argv.Length == 0 || argv[0].Equals("-h", StringComparison.OrdinalIgnoreCase) || argv[0].Equals("--help", StringComparison.OrdinalIgnoreCase)) + if (argv.Length == 0 || IsHelpArg(argv[0])) + { + Console.WriteLine(GitHubArtifactsPruneUsage); + Console.WriteLine(GitHubCachesPruneUsage); + Console.WriteLine(GitHubRunnerCleanupUsage); + return 2; + } + + return argv[0].ToLowerInvariant() switch + { + "artifacts" => CommandGitHubArtifacts(argv.Skip(1).ToArray(), cli, logger), + "caches" => CommandGitHubCaches(argv.Skip(1).ToArray(), cli, logger), + "runner" => CommandGitHubRunner(argv.Skip(1).ToArray(), cli, logger), + _ => UnknownGitHubCommand() + }; + } + + private static int CommandGitHubArtifacts(string[] argv, CliOptions cli, ILogger logger) + { + if (argv.Length == 0 || IsHelpArg(argv[0])) { - Console.WriteLine("Usage: powerforge github artifacts prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--name ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json]"); + Console.WriteLine(GitHubArtifactsPruneUsage); return 2; } - var sub = argv[0].ToLowerInvariant(); - switch (sub) + if (!argv[0].Equals("prune", StringComparison.OrdinalIgnoreCase)) { - case "artifacts": + Console.WriteLine(GitHubArtifactsPruneUsage); + return 2; + } + + var pruneArgs = argv.Skip(1).ToArray(); + var outputJson = IsJsonOutput(pruneArgs); + + GitHubArtifactCleanupSpec spec; + try + { + spec = ParseGitHubArtifactPruneArgs(pruneArgs); + } + catch (Exception ex) + { + return WriteGitHubCommandArgumentError(outputJson, "github.artifacts.prune", ex.Message, GitHubArtifactsPruneUsage, logger); + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var service = new GitHubArtifactCleanupService(cmdLogger); + var statusText = spec.DryRun ? "Planning GitHub artifact cleanup" : "Pruning GitHub artifacts"; + var result = RunWithStatus(outputJson, cli, statusText, () => service.Prune(spec)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) { - var subArgs = argv.Skip(1).ToArray(); - if (subArgs.Length == 0 || subArgs[0].Equals("-h", StringComparison.OrdinalIgnoreCase) || subArgs[0].Equals("--help", StringComparison.OrdinalIgnoreCase)) + WriteJson(new CliJsonEnvelope { - Console.WriteLine("Usage: powerforge github artifacts prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--name ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json]"); - return 2; - } + SchemaVersion = OutputSchemaVersion, + Command = "github.artifacts.prune", + Success = result.Success, + ExitCode = exitCode, + Result = CliJson.SerializeToElement(result, CliJson.Context.GitHubArtifactCleanupResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; + } - if (!subArgs[0].Equals("prune", StringComparison.OrdinalIgnoreCase)) - { - Console.WriteLine("Usage: powerforge github artifacts prune [options]"); - return 2; - } + var mode = result.DryRun ? "Dry run" : "Applied"; + logger.Info($"{mode}: {result.Repository}"); + logger.Info($"Scanned: {result.ScannedArtifacts}, matched: {result.MatchedArtifacts}"); + logger.Info($"Planned deletes: {result.PlannedDeletes} ({result.PlannedDeleteBytes} bytes)"); + if (!result.DryRun) + { + logger.Info($"Deleted: {result.DeletedArtifacts} ({result.DeletedBytes} bytes)"); + if (result.FailedDeletes > 0) + logger.Warn($"Failed deletes: {result.FailedDeletes}"); + } - var pruneArgs = subArgs.Skip(1).ToArray(); - var outputJson = IsJsonOutput(pruneArgs); + if (!string.IsNullOrWhiteSpace(result.Message)) + logger.Warn(result.Message!); - GitHubArtifactCleanupSpec spec; - try - { - spec = ParseGitHubArtifactPruneArgs(pruneArgs); - } - catch (Exception ex) - { - if (outputJson) - { - WriteJson(new CliJsonEnvelope - { - SchemaVersion = OutputSchemaVersion, - Command = "github.artifacts.prune", - Success = false, - ExitCode = 2, - Error = ex.Message - }); - return 2; - } - - logger.Error(ex.Message); - Console.WriteLine("Usage: powerforge github artifacts prune [options]"); - return 2; - } - - try + return exitCode; + } + catch (Exception ex) + { + return WriteGitHubCommandFailure(outputJson, "github.artifacts.prune", ex.Message, logger); + } + } + + private static int CommandGitHubCaches(string[] argv, CliOptions cli, ILogger logger) + { + if (argv.Length == 0 || IsHelpArg(argv[0])) + { + Console.WriteLine(GitHubCachesPruneUsage); + return 2; + } + + if (!argv[0].Equals("prune", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(GitHubCachesPruneUsage); + return 2; + } + + var pruneArgs = argv.Skip(1).ToArray(); + var outputJson = IsJsonOutput(pruneArgs); + + GitHubActionsCacheCleanupSpec spec; + try + { + spec = ParseGitHubCachesPruneArgs(pruneArgs); + } + catch (Exception ex) + { + return WriteGitHubCommandArgumentError(outputJson, "github.caches.prune", ex.Message, GitHubCachesPruneUsage, logger); + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var service = new GitHubActionsCacheCleanupService(cmdLogger); + var statusText = spec.DryRun ? "Planning GitHub cache cleanup" : "Pruning GitHub caches"; + var result = RunWithStatus(outputJson, cli, statusText, () => service.Prune(spec)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) + { + WriteJson(new CliJsonEnvelope { - var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); - var service = new GitHubArtifactCleanupService(cmdLogger); - var statusText = spec.DryRun - ? "Planning GitHub artifact cleanup" - : "Pruning GitHub artifacts"; - var result = RunWithStatus(outputJson, cli, statusText, () => service.Prune(spec)); - var exitCode = result.Success ? 0 : 1; - - if (outputJson) - { - WriteJson(new CliJsonEnvelope - { - SchemaVersion = OutputSchemaVersion, - Command = "github.artifacts.prune", - Success = result.Success, - ExitCode = exitCode, - Result = CliJson.SerializeToElement(result, CliJson.Context.GitHubArtifactCleanupResult), - Logs = LogsToJsonElement(logBuffer) - }); - return exitCode; - } - - var mode = result.DryRun ? "Dry run" : "Applied"; - logger.Info($"{mode}: {result.Repository}"); - logger.Info($"Scanned: {result.ScannedArtifacts}, matched: {result.MatchedArtifacts}"); - logger.Info($"Planned deletes: {result.PlannedDeletes} ({result.PlannedDeleteBytes} bytes)"); - if (!result.DryRun) - { - logger.Info($"Deleted: {result.DeletedArtifacts} ({result.DeletedBytes} bytes)"); - if (result.FailedDeletes > 0) - logger.Warn($"Failed deletes: {result.FailedDeletes}"); - } - - if (!string.IsNullOrWhiteSpace(result.Message)) - logger.Warn(result.Message!); - - return exitCode; - } - catch (Exception ex) + SchemaVersion = OutputSchemaVersion, + Command = "github.caches.prune", + Success = result.Success, + ExitCode = exitCode, + Result = CliJson.SerializeToElement(result, CliJson.Context.GitHubActionsCacheCleanupResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; + } + + var mode = result.DryRun ? "Dry run" : "Applied"; + logger.Info($"{mode}: {result.Repository}"); + if (result.UsageBefore is not null) + logger.Info($"GitHub cache usage before cleanup: {result.UsageBefore.ActiveCachesCount} caches, {result.UsageBefore.ActiveCachesSizeInBytes} bytes"); + logger.Info($"Scanned: {result.ScannedCaches}, matched: {result.MatchedCaches}"); + logger.Info($"Planned deletes: {result.PlannedDeletes} ({result.PlannedDeleteBytes} bytes)"); + if (!result.DryRun) + { + logger.Info($"Deleted: {result.DeletedCaches} ({result.DeletedBytes} bytes)"); + if (result.FailedDeletes > 0) + logger.Warn($"Failed deletes: {result.FailedDeletes}"); + } + + if (!string.IsNullOrWhiteSpace(result.Message)) + logger.Warn(result.Message!); + + return exitCode; + } + catch (Exception ex) + { + return WriteGitHubCommandFailure(outputJson, "github.caches.prune", ex.Message, logger); + } + } + + private static int CommandGitHubRunner(string[] argv, CliOptions cli, ILogger logger) + { + if (argv.Length == 0 || IsHelpArg(argv[0])) + { + Console.WriteLine(GitHubRunnerCleanupUsage); + return 2; + } + + if (!argv[0].Equals("cleanup", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(GitHubRunnerCleanupUsage); + return 2; + } + + var cleanupArgs = argv.Skip(1).ToArray(); + var outputJson = IsJsonOutput(cleanupArgs); + + RunnerHousekeepingSpec spec; + try + { + spec = ParseGitHubRunnerCleanupArgs(cleanupArgs); + } + catch (Exception ex) + { + return WriteGitHubCommandArgumentError(outputJson, "github.runner.cleanup", ex.Message, GitHubRunnerCleanupUsage, logger); + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var service = new RunnerHousekeepingService(cmdLogger); + var statusText = spec.DryRun ? "Planning runner housekeeping" : "Cleaning runner working sets"; + var result = RunWithStatus(outputJson, cli, statusText, () => service.Clean(spec)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) + { + WriteJson(new CliJsonEnvelope { - if (outputJson) - { - WriteJson(new CliJsonEnvelope - { - SchemaVersion = OutputSchemaVersion, - Command = "github.artifacts.prune", - Success = false, - ExitCode = 1, - Error = ex.Message - }); - return 1; - } - - logger.Error(ex.Message); - return 1; - } + SchemaVersion = OutputSchemaVersion, + Command = "github.runner.cleanup", + Success = result.Success, + ExitCode = exitCode, + Result = CliJson.SerializeToElement(result, CliJson.Context.RunnerHousekeepingResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; } - default: - Console.WriteLine("Usage: powerforge github artifacts prune [options]"); - return 2; + + var mode = result.DryRun ? "Dry run" : "Applied"; + logger.Info($"{mode}: {result.RunnerRootPath}"); + logger.Info($"Free before: {result.FreeBytesBefore} bytes"); + logger.Info($"Free after: {result.FreeBytesAfter} bytes"); + logger.Info($"Aggressive cleanup: {(result.AggressiveApplied ? "yes" : "no")}"); + if (!string.IsNullOrWhiteSpace(result.Message)) + logger.Warn(result.Message!); + + return exitCode; + } + catch (Exception ex) + { + return WriteGitHubCommandFailure(outputJson, "github.runner.cleanup", ex.Message, logger); + } + } + + private static int UnknownGitHubCommand() + { + Console.WriteLine(GitHubArtifactsPruneUsage); + Console.WriteLine(GitHubCachesPruneUsage); + Console.WriteLine(GitHubRunnerCleanupUsage); + return 2; + } + + private static int WriteGitHubCommandArgumentError(bool outputJson, string command, string error, string usage, ILogger logger) + { + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = command, + Success = false, + ExitCode = 2, + Error = error + }); + return 2; + } + + logger.Error(error); + Console.WriteLine(usage); + return 2; + } + + private static int WriteGitHubCommandFailure(bool outputJson, string command, string error, ILogger logger) + { + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = command, + Success = false, + ExitCode = 1, + Error = error + }); + return 1; } + + logger.Error(error); + return 1; } private static GitHubArtifactCleanupSpec ParseGitHubArtifactPruneArgs(string[] argv) @@ -170,31 +330,19 @@ private static GitHubArtifactCleanupSpec ParseGitHubArtifactPruneArgs(string[] a break; case "--keep": case "--keep-latest": - if (++i >= argv.Length || !int.TryParse(argv[i], out var keep)) - throw new InvalidOperationException("Invalid --keep value. Expected integer."); - if (keep < 0) - throw new InvalidOperationException("Invalid --keep value. Expected integer >= 0."); - spec.KeepLatestPerName = keep; + spec.KeepLatestPerName = ParseRequiredInt(argv, ref i, "--keep", minimum: 0); break; case "--max-age-days": case "--age-days": - if (++i >= argv.Length || !int.TryParse(argv[i], out var days)) - throw new InvalidOperationException("Invalid --max-age-days value. Expected integer."); - spec.MaxAgeDays = days < 1 ? null : days; + spec.MaxAgeDays = ParseOptionalPositiveInt(argv, ref i, "--max-age-days"); break; case "--max-delete": case "--limit": - if (++i >= argv.Length || !int.TryParse(argv[i], out var maxDelete)) - throw new InvalidOperationException("Invalid --max-delete value. Expected integer."); - if (maxDelete < 1) - throw new InvalidOperationException("Invalid --max-delete value. Expected integer >= 1."); - spec.MaxDelete = maxDelete; + spec.MaxDelete = ParseRequiredInt(argv, ref i, "--max-delete", minimum: 1); break; case "--page-size": case "--per-page": - if (++i >= argv.Length || !int.TryParse(argv[i], out var pageSize)) - throw new InvalidOperationException("Invalid --page-size value. Expected integer."); - spec.PageSize = pageSize; + spec.PageSize = ParseRequiredInt(argv, ref i, "--page-size", minimum: 1); break; case "--dry-run": spec.DryRun = true; @@ -212,23 +360,193 @@ private static GitHubArtifactCleanupSpec ParseGitHubArtifactPruneArgs(string[] a case "--json": break; default: - if (arg.StartsWith("--", StringComparison.Ordinal)) - throw new InvalidOperationException($"Unknown option: {arg}"); + ThrowOnUnknownOption(arg); break; } } - spec.IncludeNames = include - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Select(v => v.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - spec.ExcludeNames = exclude - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Select(v => v.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + spec.IncludeNames = NormalizeCsvValues(include); + spec.ExcludeNames = NormalizeCsvValues(exclude); + ResolveGitHubIdentity(spec, tokenEnv, repoEnv); + return spec; + } + + private static GitHubActionsCacheCleanupSpec ParseGitHubCachesPruneArgs(string[] argv) + { + var include = new List(); + var exclude = new List(); + var spec = new GitHubActionsCacheCleanupSpec(); + var tokenEnv = "GITHUB_TOKEN"; + var repoEnv = "GITHUB_REPOSITORY"; + for (var i = 0; i < argv.Length; i++) + { + var arg = argv[i]; + switch (arg.ToLowerInvariant()) + { + case "--repo": + case "--repository": + spec.Repository = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--token": + spec.Token = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--token-env": + tokenEnv = ++i < argv.Length ? argv[i] : tokenEnv; + break; + case "--repo-env": + repoEnv = ++i < argv.Length ? argv[i] : repoEnv; + break; + case "--api-base-url": + case "--api-url": + spec.ApiBaseUrl = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--key": + case "--keys": + case "--include": + if (++i < argv.Length) + include.AddRange(SplitCsv(argv[i])); + break; + case "--exclude": + case "--exclude-key": + case "--exclude-keys": + if (++i < argv.Length) + exclude.AddRange(SplitCsv(argv[i])); + break; + case "--keep": + case "--keep-latest": + spec.KeepLatestPerKey = ParseRequiredInt(argv, ref i, "--keep", minimum: 0); + break; + case "--max-age-days": + case "--age-days": + spec.MaxAgeDays = ParseOptionalPositiveInt(argv, ref i, "--max-age-days"); + break; + case "--max-delete": + case "--limit": + spec.MaxDelete = ParseRequiredInt(argv, ref i, "--max-delete", minimum: 1); + break; + case "--page-size": + case "--per-page": + spec.PageSize = ParseRequiredInt(argv, ref i, "--page-size", minimum: 1); + break; + case "--dry-run": + spec.DryRun = true; + break; + case "--apply": + spec.DryRun = false; + break; + case "--fail-on-delete-error": + spec.FailOnDeleteError = true; + break; + case "--output": + i++; + break; + case "--output-json": + case "--json": + break; + default: + ThrowOnUnknownOption(arg); + break; + } + } + + spec.IncludeKeys = NormalizeCsvValues(include); + spec.ExcludeKeys = NormalizeCsvValues(exclude); + ResolveGitHubIdentity(spec, tokenEnv, repoEnv); + return spec; + } + + private static RunnerHousekeepingSpec ParseGitHubRunnerCleanupArgs(string[] argv) + { + var spec = new RunnerHousekeepingSpec(); + + for (var i = 0; i < argv.Length; i++) + { + var arg = argv[i]; + switch (arg.ToLowerInvariant()) + { + case "--runner-temp": + spec.RunnerTempPath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--work-root": + spec.WorkRootPath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--runner-root": + spec.RunnerRootPath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--diag-root": + case "--diagnostics-root": + spec.DiagnosticsRootPath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--tool-cache": + case "--tool-cache-path": + spec.ToolCachePath = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--min-free-gb": + spec.MinFreeGb = ParseOptionalPositiveInt(argv, ref i, "--min-free-gb"); + break; + case "--aggressive-threshold-gb": + spec.AggressiveThresholdGb = ParseOptionalPositiveInt(argv, ref i, "--aggressive-threshold-gb"); + break; + case "--diag-retention-days": + spec.DiagnosticsRetentionDays = ParseRequiredInt(argv, ref i, "--diag-retention-days", minimum: 0); + break; + case "--actions-retention-days": + spec.ActionsRetentionDays = ParseRequiredInt(argv, ref i, "--actions-retention-days", minimum: 0); + break; + case "--tool-cache-retention-days": + spec.ToolCacheRetentionDays = ParseRequiredInt(argv, ref i, "--tool-cache-retention-days", minimum: 0); + break; + case "--dry-run": + spec.DryRun = true; + break; + case "--apply": + spec.DryRun = false; + break; + case "--aggressive": + spec.Aggressive = true; + break; + case "--allow-sudo": + spec.AllowSudo = true; + break; + case "--skip-diagnostics": + spec.CleanDiagnostics = false; + break; + case "--skip-runner-temp": + spec.CleanRunnerTemp = false; + break; + case "--skip-actions-cache": + spec.CleanActionsCache = false; + break; + case "--skip-tool-cache": + spec.CleanToolCache = false; + break; + case "--skip-dotnet-cache": + spec.ClearDotNetCaches = false; + break; + case "--skip-docker": + spec.PruneDocker = false; + break; + case "--no-docker-volumes": + spec.IncludeDockerVolumes = false; + break; + case "--output": + i++; + break; + case "--output-json": + case "--json": + break; + default: + ThrowOnUnknownOption(arg); + break; + } + } + + return spec; + } + + private static void ResolveGitHubIdentity(GitHubArtifactCleanupSpec spec, string tokenEnv, string repoEnv) + { if (string.IsNullOrWhiteSpace(spec.Repository) && !string.IsNullOrWhiteSpace(repoEnv)) spec.Repository = Environment.GetEnvironmentVariable(repoEnv)?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(spec.Token) && !string.IsNullOrWhiteSpace(tokenEnv)) @@ -238,7 +556,52 @@ private static GitHubArtifactCleanupSpec ParseGitHubArtifactPruneArgs(string[] a throw new InvalidOperationException("Repository is required. Provide --repo or set GITHUB_REPOSITORY."); if (string.IsNullOrWhiteSpace(spec.Token)) throw new InvalidOperationException("GitHub token is required. Provide --token or set GITHUB_TOKEN."); + } - return spec; + private static void ResolveGitHubIdentity(GitHubActionsCacheCleanupSpec spec, string tokenEnv, string repoEnv) + { + if (string.IsNullOrWhiteSpace(spec.Repository) && !string.IsNullOrWhiteSpace(repoEnv)) + spec.Repository = Environment.GetEnvironmentVariable(repoEnv)?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(spec.Token) && !string.IsNullOrWhiteSpace(tokenEnv)) + spec.Token = Environment.GetEnvironmentVariable(tokenEnv)?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(spec.Repository)) + throw new InvalidOperationException("Repository is required. Provide --repo or set GITHUB_REPOSITORY."); + if (string.IsNullOrWhiteSpace(spec.Token)) + throw new InvalidOperationException("GitHub token is required. Provide --token or set GITHUB_TOKEN."); + } + + private static string[] NormalizeCsvValues(IEnumerable values) + { + return values + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static int ParseRequiredInt(string[] argv, ref int index, string optionName, int minimum) + { + if (++index >= argv.Length || !int.TryParse(argv[index], out var value)) + throw new InvalidOperationException($"Invalid {optionName} value. Expected integer."); + if (value < minimum) + throw new InvalidOperationException($"Invalid {optionName} value. Expected integer >= {minimum}."); + return value; + } + + private static int? ParseOptionalPositiveInt(string[] argv, ref int index, string optionName) + { + if (++index >= argv.Length || !int.TryParse(argv[index], out var value)) + throw new InvalidOperationException($"Invalid {optionName} value. Expected integer."); + return value < 1 ? null : value; } + + private static void ThrowOnUnknownOption(string arg) + { + if (arg.StartsWith("--", StringComparison.Ordinal)) + throw new InvalidOperationException($"Unknown option: {arg}"); + } + + private static bool IsHelpArg(string arg) + => arg.Equals("-h", StringComparison.OrdinalIgnoreCase) || arg.Equals("--help", StringComparison.OrdinalIgnoreCase); } diff --git a/PowerForge.Cli/Program.Helpers.cs b/PowerForge.Cli/Program.Helpers.cs index d1dd0a26..65b10c58 100644 --- a/PowerForge.Cli/Program.Helpers.cs +++ b/PowerForge.Cli/Program.Helpers.cs @@ -35,6 +35,13 @@ powerforge publish --path [--repo ] [--tool auto|psresourceget|powe powerforge github artifacts prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--name ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json] + powerforge github caches prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--key ] + [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] + [--fail-on-delete-error] [--output json] + powerforge github runner cleanup [--runner-temp ] [--work-root ] [--runner-root ] [--diag-root ] [--tool-cache ] + [--min-free-gb ] [--aggressive-threshold-gb ] [--dry-run|--apply] [--aggressive] [--allow-sudo] + [--skip-diagnostics] [--skip-runner-temp] [--skip-actions-cache] [--skip-tool-cache] [--skip-dotnet-cache] + [--skip-docker] [--no-docker-volumes] [--output json] --verbose, -Verbose Enable verbose diagnostics --diagnostics Include logs in JSON output --quiet, -q Suppress non-essential output diff --git a/PowerForge.Tests/GitHubActionsCacheCleanupServiceTests.cs b/PowerForge.Tests/GitHubActionsCacheCleanupServiceTests.cs new file mode 100644 index 00000000..e5394c9e --- /dev/null +++ b/PowerForge.Tests/GitHubActionsCacheCleanupServiceTests.cs @@ -0,0 +1,219 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace PowerForge.Tests; + +public sealed class GitHubActionsCacheCleanupServiceTests +{ + [Fact] + public void Prune_DryRun_UsesAgeThresholdAndUsageSnapshot() + { + var caches = new[] + { + Cache(id: 1, key: "ubuntu-nuget", daysAgo: 30), + Cache(id: 2, key: "ubuntu-nuget", daysAgo: 10), + Cache(id: 3, key: "ubuntu-nuget", daysAgo: 1), + Cache(id: 4, key: "windows-nuget", daysAgo: 20), + Cache(id: 5, key: "keep-me", daysAgo: 40) + }; + + var handler = new FakeGitHubCachesHandler(caches, activeCachesCount: 48, activeCachesBytes: 9_950_000_000L); + using var client = new HttpClient(handler); + var service = new GitHubActionsCacheCleanupService(new NullLogger(), client); + + var result = service.Prune(new GitHubActionsCacheCleanupSpec + { + Repository = "EvotecIT/PSPublishModule", + Token = "test-token", + ExcludeKeys = new[] { "keep-me" }, + KeepLatestPerKey = 1, + MaxAgeDays = 7, + DryRun = true + }); + + Assert.True(result.Success); + Assert.True(result.DryRun); + Assert.NotNull(result.UsageBefore); + Assert.Equal(48, result.UsageBefore!.ActiveCachesCount); + Assert.Equal(5, result.ScannedCaches); + Assert.Equal(4, result.MatchedCaches); + Assert.Equal(2, result.PlannedDeletes); + Assert.Equal(new long[] { 1, 2 }, result.Planned.Select(c => c.Id).OrderBy(v => v).ToArray()); + Assert.DoesNotContain(handler.DeletedCacheIds, _ => true); + } + + [Fact] + public void Prune_Apply_DeletesCachesAndReportsFailures() + { + var caches = new[] + { + Cache(id: 11, key: "ubuntu-node", daysAgo: 25), + Cache(id: 12, key: "ubuntu-node", daysAgo: 18) + }; + + var handler = new FakeGitHubCachesHandler(caches, failDeleteIds: new[] { 12L }); + using var client = new HttpClient(handler); + var service = new GitHubActionsCacheCleanupService(new NullLogger(), client); + + var result = service.Prune(new GitHubActionsCacheCleanupSpec + { + Repository = "EvotecIT/HtmlForgeX", + Token = "test-token", + KeepLatestPerKey = 0, + MaxAgeDays = null, + DryRun = false, + FailOnDeleteError = true + }); + + Assert.False(result.Success); + Assert.Equal(2, result.PlannedDeletes); + Assert.Equal(1, result.DeletedCaches); + Assert.Equal(1, result.FailedDeletes); + Assert.Contains(11L, handler.DeletedCacheIds); + Assert.Contains(12L, handler.DeletedCacheIds); + Assert.Single(result.Failed); + Assert.Equal(12L, result.Failed[0].Id); + Assert.NotNull(result.Failed[0].DeleteError); + } + + [Fact] + public void Prune_IncludePattern_FiltersKeys() + { + var caches = new[] + { + Cache(id: 21, key: "ubuntu-nuget", daysAgo: 15), + Cache(id: 22, key: "windows-nuget", daysAgo: 15), + Cache(id: 23, key: "ubuntu-pnpm", daysAgo: 15) + }; + + var handler = new FakeGitHubCachesHandler(caches); + using var client = new HttpClient(handler); + var service = new GitHubActionsCacheCleanupService(new NullLogger(), client); + + var result = service.Prune(new GitHubActionsCacheCleanupSpec + { + Repository = "EvotecIT/CodeGlyphX", + Token = "test-token", + IncludeKeys = new[] { "ubuntu-*" }, + KeepLatestPerKey = 0, + MaxAgeDays = null, + DryRun = true + }); + + Assert.Equal(new long[] { 21, 23 }, result.Planned.Select(c => c.Id).OrderBy(v => v).ToArray()); + } + + private static FakeCache Cache(long id, string key, int daysAgo) + { + var timestamp = DateTimeOffset.UtcNow.AddDays(-daysAgo); + return new FakeCache + { + Id = id, + Key = key, + Ref = "refs/heads/main", + Version = "v1", + SizeInBytes = 1024 + id, + CreatedAt = timestamp, + LastAccessedAt = timestamp + }; + } + + private sealed class FakeGitHubCachesHandler : HttpMessageHandler + { + private readonly FakeCache[] _caches; + private readonly HashSet _failDeleteIds; + private readonly int _activeCachesCount; + private readonly long _activeCachesBytes; + + public List DeletedCacheIds { get; } = new(); + + public FakeGitHubCachesHandler( + FakeCache[] caches, + int activeCachesCount = 0, + long activeCachesBytes = 0, + IEnumerable? failDeleteIds = null) + { + _caches = caches ?? Array.Empty(); + _activeCachesCount = activeCachesCount == 0 ? _caches.Length : activeCachesCount; + _activeCachesBytes = activeCachesBytes == 0 ? _caches.Sum(c => c.SizeInBytes) : activeCachesBytes; + _failDeleteIds = failDeleteIds is null ? new HashSet() : new HashSet(failDeleteIds); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + + if (request.Method == HttpMethod.Get && path.Contains("/actions/cache/usage", StringComparison.OrdinalIgnoreCase)) + { + var usage = JsonSerializer.Serialize(new + { + active_caches_count = _activeCachesCount, + active_caches_size_in_bytes = _activeCachesBytes + }); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(usage, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Get && path.Contains("/actions/caches", StringComparison.OrdinalIgnoreCase)) + { + var payload = JsonSerializer.Serialize(new + { + total_count = _caches.Length, + actions_caches = _caches.Select(c => new + { + id = c.Id, + key = c.Key, + @ref = c.Ref, + version = c.Version, + size_in_bytes = c.SizeInBytes, + created_at = c.CreatedAt.ToString("O"), + last_accessed_at = c.LastAccessedAt.ToString("O") + }).ToArray() + }); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Delete && path.Contains("/actions/caches/", StringComparison.OrdinalIgnoreCase)) + { + var idText = request.RequestUri?.Segments.LastOrDefault()?.Trim('/'); + _ = long.TryParse(idText, out var cacheId); + DeletedCacheIds.Add(cacheId); + + if (_failDeleteIds.Contains(cacheId)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{\"message\":\"delete failed\"}", Encoding.UTF8, "application/json") + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }); + } + } + + private sealed class FakeCache + { + public long Id { get; set; } + public string Key { get; set; } = string.Empty; + public string Ref { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public long SizeInBytes { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastAccessedAt { get; set; } + } +} diff --git a/PowerForge.Tests/RunnerHousekeepingServiceTests.cs b/PowerForge.Tests/RunnerHousekeepingServiceTests.cs new file mode 100644 index 00000000..c732da7f --- /dev/null +++ b/PowerForge.Tests/RunnerHousekeepingServiceTests.cs @@ -0,0 +1,115 @@ +namespace PowerForge.Tests; + +public sealed class RunnerHousekeepingServiceTests +{ + [Fact] + public void Clean_DryRun_PlansDiagnosticsAndRunnerTempCleanup() + { + var root = CreateSandbox(); + try + { + var runnerRoot = Path.Combine(root, "runner"); + var workRoot = Path.Combine(runnerRoot, "_work"); + var runnerTemp = Path.Combine(workRoot, "_temp"); + var diagRoot = Path.Combine(runnerRoot, "_diag"); + Directory.CreateDirectory(runnerTemp); + Directory.CreateDirectory(diagRoot); + + var tempFile = Path.Combine(runnerTemp, "temp.txt"); + File.WriteAllText(tempFile, "temp"); + + var oldDiag = Path.Combine(diagRoot, "old.log"); + File.WriteAllText(oldDiag, "old"); + File.SetLastWriteTimeUtc(oldDiag, DateTime.UtcNow.AddDays(-20)); + + var service = new RunnerHousekeepingService(new NullLogger()); + var result = service.Clean(new RunnerHousekeepingSpec + { + RunnerTempPath = runnerTemp, + RunnerRootPath = runnerRoot, + WorkRootPath = workRoot, + DiagnosticsRootPath = diagRoot, + DryRun = true, + Aggressive = false, + ClearDotNetCaches = false, + PruneDocker = false + }); + + Assert.True(result.Success); + Assert.True(result.DryRun); + Assert.Equal(2, result.Steps.Length); + Assert.Contains(result.Steps, s => s.Id == "diag" && s.DryRun && s.EntriesAffected == 1); + Assert.Contains(result.Steps, s => s.Id == "runner-temp" && s.DryRun && s.EntriesAffected == 1); + Assert.True(File.Exists(oldDiag)); + Assert.True(File.Exists(tempFile)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Clean_Apply_AggressiveModeDeletesOldDirectories() + { + var root = CreateSandbox(); + try + { + var runnerRoot = Path.Combine(root, "runner"); + var workRoot = Path.Combine(runnerRoot, "_work"); + var runnerTemp = Path.Combine(workRoot, "_temp"); + var actionsRoot = Path.Combine(workRoot, "_actions"); + var toolCache = Path.Combine(root, "toolcache"); + + Directory.CreateDirectory(runnerTemp); + Directory.CreateDirectory(actionsRoot); + Directory.CreateDirectory(toolCache); + + var oldActionDir = Path.Combine(actionsRoot, "old-action"); + var oldToolDir = Path.Combine(toolCache, "old-tool"); + Directory.CreateDirectory(oldActionDir); + Directory.CreateDirectory(oldToolDir); + + File.WriteAllText(Path.Combine(oldActionDir, "a.txt"), "x"); + File.WriteAllText(Path.Combine(oldToolDir, "b.txt"), "x"); + Directory.SetLastWriteTimeUtc(oldActionDir, DateTime.UtcNow.AddDays(-10)); + Directory.SetLastWriteTimeUtc(oldToolDir, DateTime.UtcNow.AddDays(-40)); + + var service = new RunnerHousekeepingService(new NullLogger()); + var result = service.Clean(new RunnerHousekeepingSpec + { + RunnerTempPath = runnerTemp, + RunnerRootPath = runnerRoot, + WorkRootPath = workRoot, + ToolCachePath = toolCache, + DryRun = false, + Aggressive = true, + ClearDotNetCaches = false, + PruneDocker = false + }); + + Assert.True(result.Success); + Assert.True(result.AggressiveApplied); + Assert.DoesNotContain(result.Steps, s => !s.Success); + Assert.False(Directory.Exists(oldActionDir)); + Assert.False(Directory.Exists(oldToolDir)); + } + finally + { + TryDelete(root); + } + } + + private static string CreateSandbox() + { + var path = Path.Combine(Path.GetTempPath(), "PowerForge.RunnerHousekeepingTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } +} diff --git a/PowerForge/Models/GitHubActionsCacheCleanup.cs b/PowerForge/Models/GitHubActionsCacheCleanup.cs new file mode 100644 index 00000000..6afdcc6f --- /dev/null +++ b/PowerForge/Models/GitHubActionsCacheCleanup.cs @@ -0,0 +1,254 @@ +using System; + +namespace PowerForge; + +/// +/// Specification for pruning GitHub Actions dependency caches in a repository. +/// +public sealed class GitHubActionsCacheCleanupSpec +{ + /// + /// GitHub API base URL (for example https://api.github.com/). + /// + public string? ApiBaseUrl { get; set; } + + /// + /// Repository in owner/repo format. + /// + public string Repository { get; set; } = string.Empty; + + /// + /// GitHub token used to access the Actions caches API. + /// + public string Token { get; set; } = string.Empty; + + /// + /// Cache key patterns to include (wildcards and re: regex supported). + /// When empty, all cache keys are eligible. + /// + public string[] IncludeKeys { get; set; } = Array.Empty(); + + /// + /// Cache key patterns to exclude (wildcards and re: regex supported). + /// + public string[] ExcludeKeys { get; set; } = Array.Empty(); + + /// + /// Number of newest caches to keep per cache key. + /// + public int KeepLatestPerKey { get; set; } = 1; + + /// + /// Minimum cache age in days before deletion is allowed. + /// Set to null (or less than 1) to disable age filtering. + /// + public int? MaxAgeDays { get; set; } = 14; + + /// + /// Maximum number of caches to delete in a single run. + /// + public int MaxDelete { get; set; } = 200; + + /// + /// Number of caches returned per GitHub API page (1-100). + /// + public int PageSize { get; set; } = 100; + + /// + /// When true, only plans deletions and does not call delete endpoints. + /// + public bool DryRun { get; set; } = true; + + /// + /// When true, the run is marked failed when any delete request fails. + /// + public bool FailOnDeleteError { get; set; } +} + +/// +/// Single cache record included in cleanup output. +/// +public sealed class GitHubActionsCacheCleanupItem +{ + /// + /// GitHub cache identifier. + /// + public long Id { get; set; } + + /// + /// Cache key. + /// + public string Key { get; set; } = string.Empty; + + /// + /// Git reference associated with the cache. + /// + public string? Ref { get; set; } + + /// + /// Cache version fingerprint reported by GitHub. + /// + public string? Version { get; set; } + + /// + /// Size in bytes. + /// + public long SizeInBytes { get; set; } + + /// + /// Creation timestamp (UTC) when available. + /// + public DateTimeOffset? CreatedAt { get; set; } + + /// + /// Last access timestamp (UTC) when available. + /// + public DateTimeOffset? LastAccessedAt { get; set; } + + /// + /// Reason this item was selected (or failed) in cleanup output. + /// + public string? Reason { get; set; } + + /// + /// HTTP status code from delete operation (when available). + /// + public int? DeleteStatusCode { get; set; } + + /// + /// Error message from delete operation (when available). + /// + public string? DeleteError { get; set; } +} + +/// +/// Repository cache usage snapshot returned by GitHub. +/// +public sealed class GitHubActionsCacheUsage +{ + /// + /// Total cache count reported by GitHub. + /// + public int ActiveCachesCount { get; set; } + + /// + /// Total cache size in bytes reported by GitHub. + /// + public long ActiveCachesSizeInBytes { get; set; } +} + +/// +/// Result summary for a GitHub Actions cache cleanup run. +/// +public sealed class GitHubActionsCacheCleanupResult +{ + /// + /// Repository in owner/repo format. + /// + public string Repository { get; set; } = string.Empty; + + /// + /// Effective include key patterns used by the run. + /// + public string[] IncludeKeys { get; set; } = Array.Empty(); + + /// + /// Effective exclude key patterns used by the run. + /// + public string[] ExcludeKeys { get; set; } = Array.Empty(); + + /// + /// Number of newest caches kept per cache key. + /// + public int KeepLatestPerKey { get; set; } + + /// + /// Minimum age requirement (days) applied during selection. + /// + public int? MaxAgeDays { get; set; } + + /// + /// Maximum number of deletions allowed by this run. + /// + public int MaxDelete { get; set; } + + /// + /// Whether run executed in dry-run mode. + /// + public bool DryRun { get; set; } + + /// + /// Cache usage reported by GitHub before cleanup started. + /// + public GitHubActionsCacheUsage? UsageBefore { get; set; } + + /// + /// Total caches scanned from GitHub. + /// + public int ScannedCaches { get; set; } + + /// + /// Caches matching include/exclude filters. + /// + public int MatchedCaches { get; set; } + + /// + /// Number of caches skipped because they are in the newest keep window. + /// + public int KeptByRecentWindow { get; set; } + + /// + /// Number of caches skipped because they are newer than the age threshold. + /// + public int KeptByAgeThreshold { get; set; } + + /// + /// Number of caches planned for deletion. + /// + public int PlannedDeletes { get; set; } + + /// + /// Total size in bytes of planned deletions. + /// + public long PlannedDeleteBytes { get; set; } + + /// + /// Number of caches successfully deleted. + /// + public int DeletedCaches { get; set; } + + /// + /// Total size in bytes of successfully deleted caches. + /// + public long DeletedBytes { get; set; } + + /// + /// Number of caches that failed deletion. + /// + public int FailedDeletes { get; set; } + + /// + /// Whether the run completed successfully. + /// + public bool Success { get; set; } = true; + + /// + /// Optional warning or non-fatal message. + /// + public string? Message { get; set; } + + /// + /// Caches selected for deletion. + /// + public GitHubActionsCacheCleanupItem[] Planned { get; set; } = Array.Empty(); + + /// + /// Caches successfully deleted. + /// + public GitHubActionsCacheCleanupItem[] Deleted { get; set; } = Array.Empty(); + + /// + /// Caches that failed deletion. + /// + public GitHubActionsCacheCleanupItem[] Failed { get; set; } = Array.Empty(); +} diff --git a/PowerForge/Models/RunnerHousekeeping.cs b/PowerForge/Models/RunnerHousekeeping.cs new file mode 100644 index 00000000..941c6ac3 --- /dev/null +++ b/PowerForge/Models/RunnerHousekeeping.cs @@ -0,0 +1,242 @@ +using System; + +namespace PowerForge; + +/// +/// Specification for reclaiming disk space on a GitHub Actions runner. +/// +public sealed class RunnerHousekeepingSpec +{ + /// + /// Optional explicit runner temp directory. When omitted, RUNNER_TEMP is used. + /// + public string? RunnerTempPath { get; set; } + + /// + /// Optional explicit work root. When omitted, it is derived from the runner temp path or GITHUB_WORKSPACE. + /// + public string? WorkRootPath { get; set; } + + /// + /// Optional explicit runner root. When omitted, it is derived from the work root. + /// + public string? RunnerRootPath { get; set; } + + /// + /// Optional explicit diagnostics root. When omitted, <runnerRoot>/_diag is used. + /// + public string? DiagnosticsRootPath { get; set; } + + /// + /// Optional explicit tool cache path. When omitted, RUNNER_TOOL_CACHE or AGENT_TOOLSDIRECTORY is used. + /// + public string? ToolCachePath { get; set; } + + /// + /// Minimum free disk size, in GiB, required after cleanup. Set to null to disable the threshold. + /// + public int? MinFreeGb { get; set; } = 20; + + /// + /// Free disk threshold, in GiB, below which aggressive cleanup is enabled. + /// When omitted, the service uses MinFreeGb + 5. + /// + public int? AggressiveThresholdGb { get; set; } + + /// + /// Retention window for runner diagnostics files. + /// + public int DiagnosticsRetentionDays { get; set; } = 14; + + /// + /// Retention window for cached GitHub action working sets under _actions. + /// + public int ActionsRetentionDays { get; set; } = 7; + + /// + /// Retention window for runner tool cache directories. + /// + public int ToolCacheRetentionDays { get; set; } = 30; + + /// + /// When true, only plans deletions and external commands without executing them. + /// + public bool DryRun { get; set; } = true; + + /// + /// Forces aggressive cleanup even when free disk is above the threshold. + /// + public bool Aggressive { get; set; } + + /// + /// When true, runner diagnostics cleanup is enabled. + /// + public bool CleanDiagnostics { get; set; } = true; + + /// + /// When true, runner temp contents are cleaned. + /// + public bool CleanRunnerTemp { get; set; } = true; + + /// + /// When true, GitHub actions working sets under _actions are cleaned during aggressive cleanup. + /// + public bool CleanActionsCache { get; set; } = true; + + /// + /// When true, runner tool cache directories are cleaned during aggressive cleanup. + /// + public bool CleanToolCache { get; set; } = true; + + /// + /// When true, dotnet nuget locals all --clear is executed during aggressive cleanup. + /// + public bool ClearDotNetCaches { get; set; } = true; + + /// + /// When true, docker system prune is executed during aggressive cleanup. + /// + public bool PruneDocker { get; set; } = true; + + /// + /// When true, Docker volumes are included in the prune operation. + /// + public bool IncludeDockerVolumes { get; set; } = true; + + /// + /// When true, the service may use sudo for directory deletion on Unix when direct deletion fails. + /// + public bool AllowSudo { get; set; } +} + +/// +/// Single cleanup step included in runner housekeeping output. +/// +public sealed class RunnerHousekeepingStepResult +{ + /// + /// Stable step identifier. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Human-readable step title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Whether the step was skipped. + /// + public bool Skipped { get; set; } + + /// + /// Whether the step was a dry-run preview. + /// + public bool DryRun { get; set; } + + /// + /// Whether the step completed successfully. + /// + public bool Success { get; set; } = true; + + /// + /// Optional step message. + /// + public string? Message { get; set; } + + /// + /// Number of filesystem entries deleted or planned. + /// + public int EntriesAffected { get; set; } + + /// + /// Optional command text executed by the step. + /// + public string? Command { get; set; } + + /// + /// Process exit code when the step runs an external command. + /// + public int? ExitCode { get; set; } + + /// + /// Filesystem paths touched or planned by the step. + /// + public string[] Targets { get; set; } = Array.Empty(); +} + +/// +/// Result summary for a runner housekeeping run. +/// +public sealed class RunnerHousekeepingResult +{ + /// + /// Runner root used by the cleanup run. + /// + public string RunnerRootPath { get; set; } = string.Empty; + + /// + /// Work root used by the cleanup run. + /// + public string WorkRootPath { get; set; } = string.Empty; + + /// + /// Runner temp path used by the cleanup run. + /// + public string RunnerTempPath { get; set; } = string.Empty; + + /// + /// Optional diagnostics root used by the cleanup run. + /// + public string? DiagnosticsRootPath { get; set; } + + /// + /// Optional tool cache path used by the cleanup run. + /// + public string? ToolCachePath { get; set; } + + /// + /// Free disk before cleanup, in bytes. + /// + public long FreeBytesBefore { get; set; } + + /// + /// Free disk after cleanup, in bytes. + /// + public long FreeBytesAfter { get; set; } + + /// + /// Minimum required free disk after cleanup, in bytes. + /// + public long? RequiredFreeBytes { get; set; } + + /// + /// Aggressive threshold used by the run, in bytes. + /// + public long? AggressiveThresholdBytes { get; set; } + + /// + /// Whether aggressive cleanup was executed. + /// + public bool AggressiveApplied { get; set; } + + /// + /// Whether run executed in dry-run mode. + /// + public bool DryRun { get; set; } + + /// + /// Whether the run completed successfully. + /// + public bool Success { get; set; } = true; + + /// + /// Optional warning or error message. + /// + public string? Message { get; set; } + + /// + /// Detailed step results. + /// + public RunnerHousekeepingStepResult[] Steps { get; set; } = Array.Empty(); +} diff --git a/PowerForge/Services/GitHubActionsCacheCleanupService.cs b/PowerForge/Services/GitHubActionsCacheCleanupService.cs new file mode 100644 index 00000000..518ed1ba --- /dev/null +++ b/PowerForge/Services/GitHubActionsCacheCleanupService.cs @@ -0,0 +1,560 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace PowerForge; + +/// +/// GitHub Actions cache cleanup service used to reclaim repository cache quota. +/// +public sealed class GitHubActionsCacheCleanupService +{ + private readonly ILogger _logger; + private readonly HttpClient _client; + private static readonly HttpClient SharedClient = CreateSharedClient(); + + /// + /// Creates a cache cleanup service with a logger and optional custom HTTP client. + /// + /// Logger used for progress and diagnostics. + /// Optional HTTP client (for tests/custom transports). + public GitHubActionsCacheCleanupService(ILogger logger, HttpClient? client = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _client = client ?? SharedClient; + } + + /// + /// Executes a cleanup run against GitHub Actions caches. + /// + /// Cleanup specification. + /// Run summary with planned/deleted items and counters. + public GitHubActionsCacheCleanupResult Prune(GitHubActionsCacheCleanupSpec spec) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var normalized = NormalizeSpec(spec); + var usageBefore = TryGetUsage(normalized.ApiBaseUri, normalized.Repository, normalized.Token); + var allCaches = ListCaches(normalized.ApiBaseUri, normalized.Repository, normalized.Token, normalized.PageSize); + var now = DateTimeOffset.UtcNow; + var ageCutoff = normalized.MaxAgeDays is > 0 + ? now.AddDays(-normalized.MaxAgeDays.Value) + : (DateTimeOffset?)null; + + var matched = allCaches + .Where(c => MatchesAnyPattern(c.Key, normalized.IncludeKeys, defaultWhenEmpty: true)) + .Where(c => !MatchesAnyPattern(c.Key, normalized.ExcludeKeys, defaultWhenEmpty: false)) + .ToArray(); + + var planned = new List(); + var keptByRecentWindow = 0; + var keptByAgeThreshold = 0; + + foreach (var byKey in matched.GroupBy(c => c.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + { + var ordered = byKey + .OrderByDescending(GetSortTimestamp) + .ThenByDescending(c => c.Id) + .ToArray(); + + for (var index = 0; index < ordered.Length; index++) + { + var cache = ordered[index]; + if (index < normalized.KeepLatestPerKey) + { + keptByRecentWindow++; + continue; + } + + if (ageCutoff is not null && GetSortTimestamp(cache) > ageCutoff.Value) + { + keptByAgeThreshold++; + continue; + } + + planned.Add(ToItem(cache, BuildSelectionReason(normalized, ageCutoff))); + } + } + + var orderedPlanned = planned + .OrderBy(c => c.LastAccessedAt ?? c.CreatedAt ?? DateTimeOffset.MinValue) + .ThenBy(c => c.Key, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.Id) + .Take(normalized.MaxDelete) + .ToArray(); + + var result = new GitHubActionsCacheCleanupResult + { + Repository = normalized.Repository, + IncludeKeys = normalized.IncludeKeys, + ExcludeKeys = normalized.ExcludeKeys, + KeepLatestPerKey = normalized.KeepLatestPerKey, + MaxAgeDays = normalized.MaxAgeDays is > 0 ? normalized.MaxAgeDays : null, + MaxDelete = normalized.MaxDelete, + DryRun = normalized.DryRun, + UsageBefore = usageBefore, + ScannedCaches = allCaches.Length, + MatchedCaches = matched.Length, + KeptByRecentWindow = keptByRecentWindow, + KeptByAgeThreshold = keptByAgeThreshold, + Planned = orderedPlanned, + PlannedDeletes = orderedPlanned.Length, + PlannedDeleteBytes = orderedPlanned.Sum(c => c.SizeInBytes), + Success = true + }; + + _logger.Info($"GitHub caches scanned: {result.ScannedCaches}, matched: {result.MatchedCaches}, planned: {result.PlannedDeletes}."); + + if (normalized.DryRun || orderedPlanned.Length == 0) + { + if (normalized.DryRun) + _logger.Info("GitHub cache cleanup dry-run enabled. No delete requests were sent."); + return result; + } + + var deleted = new List(); + var failed = new List(); + + foreach (var item in orderedPlanned) + { + var deleteResult = DeleteCache(normalized.ApiBaseUri, normalized.Repository, normalized.Token, item.Id); + if (deleteResult.Ok) + { + deleted.Add(item); + _logger.Verbose($"Deleted cache #{item.Id} ({item.Key})."); + continue; + } + + item.DeleteStatusCode = deleteResult.StatusCode; + item.DeleteError = deleteResult.Error; + failed.Add(item); + _logger.Warn($"Failed to delete cache #{item.Id} ({item.Key}): {deleteResult.Error}"); + } + + result.Deleted = deleted.ToArray(); + result.Failed = failed.ToArray(); + result.DeletedCaches = deleted.Count; + result.DeletedBytes = deleted.Sum(c => c.SizeInBytes); + result.FailedDeletes = failed.Count; + result.Success = failed.Count == 0 || !normalized.FailOnDeleteError; + + if (!result.Success) + result.Message = "One or more cache delete operations failed."; + else if (failed.Count > 0) + result.Message = "Cleanup finished with non-fatal delete errors."; + + return result; + } + + private sealed class GitHubActionsCacheRecord + { + public long Id { get; set; } + public string Key { get; set; } = string.Empty; + public string? Ref { get; set; } + public string? Version { get; set; } + public long SizeInBytes { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public DateTimeOffset? LastAccessedAt { get; set; } + } + + private sealed class NormalizedSpec + { + public Uri ApiBaseUri { get; set; } = new Uri("https://api.github.com/", UriKind.Absolute); + public string Repository { get; set; } = string.Empty; + public string Token { get; set; } = string.Empty; + public string[] IncludeKeys { get; set; } = Array.Empty(); + public string[] ExcludeKeys { get; set; } = Array.Empty(); + public int KeepLatestPerKey { get; set; } + public int? MaxAgeDays { get; set; } + public int MaxDelete { get; set; } + public int PageSize { get; set; } + public bool DryRun { get; set; } + public bool FailOnDeleteError { get; set; } + } + + private NormalizedSpec NormalizeSpec(GitHubActionsCacheCleanupSpec spec) + { + var repository = NormalizeRepository(spec.Repository); + if (string.IsNullOrWhiteSpace(repository)) + throw new InvalidOperationException("Repository is required (owner/repo)."); + + var token = (spec.Token ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("GitHub token is required."); + + return new NormalizedSpec + { + ApiBaseUri = NormalizeApiBaseUri(spec.ApiBaseUrl), + Repository = repository, + Token = token, + IncludeKeys = NormalizePatterns(spec.IncludeKeys), + ExcludeKeys = NormalizePatterns(spec.ExcludeKeys), + KeepLatestPerKey = Math.Max(0, spec.KeepLatestPerKey), + MaxAgeDays = spec.MaxAgeDays is < 1 ? null : spec.MaxAgeDays, + MaxDelete = Math.Max(1, spec.MaxDelete), + PageSize = Clamp(spec.PageSize, 1, 100), + DryRun = spec.DryRun, + FailOnDeleteError = spec.FailOnDeleteError + }; + } + + private GitHubActionsCacheUsage? TryGetUsage(Uri apiBaseUri, string repository, string token) + { + try + { + var uri = BuildApiUri(apiBaseUri, repository, "actions/cache/usage"); + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using var response = _client.SendAsync(request).ConfigureAwait(false).GetAwaiter().GetResult(); + var body = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + _logger.Verbose($"GitHub cache usage lookup failed: HTTP {(int)response.StatusCode}."); + return null; + } + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + return new GitHubActionsCacheUsage + { + ActiveCachesCount = TryGetInt32(root, "active_caches_count"), + ActiveCachesSizeInBytes = TryGetInt64(root, "active_caches_size_in_bytes") + }; + } + catch (Exception ex) + { + _logger.Verbose($"GitHub cache usage lookup failed: {ex.Message}"); + return null; + } + } + + private GitHubActionsCacheRecord[] ListCaches(Uri apiBaseUri, string repository, string token, int pageSize) + { + var records = new List(); + var page = 1; + int? totalCount = null; + + while (true) + { + var uri = BuildApiUri(apiBaseUri, repository, $"actions/caches?per_page={pageSize}&page={page}"); + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using var response = _client.SendAsync(request).ConfigureAwait(false).GetAwaiter().GetResult(); + var body = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + throw BuildHttpFailure("listing caches", response, body); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (root.TryGetProperty("total_count", out var totalElement) && totalElement.ValueKind == JsonValueKind.Number) + totalCount = totalElement.GetInt32(); + + if (!root.TryGetProperty("actions_caches", out var itemsElement) || itemsElement.ValueKind != JsonValueKind.Array) + break; + + var pageItems = itemsElement.EnumerateArray().Select(ParseCache).ToArray(); + if (pageItems.Length == 0) + break; + + records.AddRange(pageItems); + + if (pageItems.Length < pageSize) + break; + + if (totalCount.HasValue && records.Count >= totalCount.Value) + break; + + page++; + } + + return records.ToArray(); + } + + private (bool Ok, int? StatusCode, string? Error) DeleteCache(Uri apiBaseUri, string repository, string token, long cacheId) + { + var uri = BuildApiUri(apiBaseUri, repository, $"actions/caches/{cacheId}"); + using var request = new HttpRequestMessage(HttpMethod.Delete, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using var response = _client.SendAsync(request).ConfigureAwait(false).GetAwaiter().GetResult(); + if (response.IsSuccessStatusCode) + return (true, (int)response.StatusCode, null); + + var body = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + var error = BuildHttpFailure("deleting cache", response, body).Message; + return (false, (int)response.StatusCode, error); + } + + private static DateTimeOffset GetSortTimestamp(GitHubActionsCacheRecord record) + => record.LastAccessedAt ?? record.CreatedAt ?? DateTimeOffset.MinValue; + + private static GitHubActionsCacheCleanupItem ToItem(GitHubActionsCacheRecord record, string? reason) + { + return new GitHubActionsCacheCleanupItem + { + Id = record.Id, + Key = record.Key, + Ref = record.Ref, + Version = record.Version, + SizeInBytes = record.SizeInBytes, + CreatedAt = record.CreatedAt, + LastAccessedAt = record.LastAccessedAt, + Reason = reason + }; + } + + private static string BuildSelectionReason(NormalizedSpec spec, DateTimeOffset? ageCutoff) + { + if (ageCutoff is null) + return $"older-than-keep-window:{spec.KeepLatestPerKey}"; + + return $"older-than-keep-window:{spec.KeepLatestPerKey};older-than-days:{spec.MaxAgeDays}"; + } + + private static string[] NormalizePatterns(string[]? patterns) + { + return (patterns ?? Array.Empty()) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool MatchesAnyPattern(string value, string[] patterns, bool defaultWhenEmpty) + { + if (patterns is null || patterns.Length == 0) + return defaultWhenEmpty; + + foreach (var pattern in patterns) + { + if (MatchSinglePattern(value, pattern)) + return true; + } + + return false; + } + + private static bool MatchSinglePattern(string value, string pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) + return false; + + var trimmed = pattern.Trim(); + if (trimmed.StartsWith("re:", StringComparison.OrdinalIgnoreCase)) + { + var expression = trimmed.Substring(3); + if (string.IsNullOrWhiteSpace(expression)) + return false; + + return Regex.IsMatch(value, expression, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + if (!trimmed.Contains('*') && !trimmed.Contains('?')) + return string.Equals(value, trimmed, StringComparison.OrdinalIgnoreCase); + + var regexPattern = "^" + Regex.Escape(trimmed) + .Replace("\\*", ".*") + .Replace("\\?", ".") + "$"; + + return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private static GitHubActionsCacheRecord ParseCache(JsonElement cache) + { + return new GitHubActionsCacheRecord + { + Id = TryGetInt64(cache, "id"), + Key = TryGetString(cache, "key") ?? string.Empty, + Ref = TryGetString(cache, "ref"), + Version = TryGetString(cache, "version"), + SizeInBytes = TryGetInt64(cache, "size_in_bytes"), + CreatedAt = TryGetDateTimeOffset(cache, "created_at"), + LastAccessedAt = TryGetDateTimeOffset(cache, "last_accessed_at") + }; + } + + private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out value)) + return true; + + value = default; + return false; + } + + private static string? TryGetString(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + return null; + + return value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } + + private static long TryGetInt64(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + return 0; + + if (value.ValueKind != JsonValueKind.Number) + return 0; + + return value.TryGetInt64(out var parsed) ? parsed : 0; + } + + private static int TryGetInt32(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + return 0; + + if (value.ValueKind != JsonValueKind.Number) + return 0; + + return value.TryGetInt32(out var parsed) ? parsed : 0; + } + + private static DateTimeOffset? TryGetDateTimeOffset(JsonElement element, string propertyName) + { + var text = TryGetString(element, propertyName); + if (string.IsNullOrWhiteSpace(text)) + return null; + + return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed) + ? parsed + : (DateTimeOffset?)null; + } + + private static Exception BuildHttpFailure(string operation, HttpResponseMessage response, string? body) + { + var status = (int)response.StatusCode; + var reason = response.ReasonPhrase ?? "HTTP error"; + var trimmedBody = TrimForMessage(body); + var rateLimitHint = BuildRateLimitHint(response); + var details = string.IsNullOrWhiteSpace(trimmedBody) ? reason : $"{reason}. {trimmedBody}"; + if (!string.IsNullOrWhiteSpace(rateLimitHint)) + details = $"{details} {rateLimitHint}"; + + return new InvalidOperationException($"GitHub API error while {operation} (HTTP {status}). {details}".Trim()); + } + + private static string? BuildRateLimitHint(HttpResponseMessage response) + { + if (response.StatusCode != HttpStatusCode.Forbidden) + return null; + + if (!response.Headers.TryGetValues("X-RateLimit-Remaining", out var remainingValues)) + return null; + + var remaining = (remainingValues ?? Array.Empty()).FirstOrDefault(); + if (!string.Equals(remaining, "0", StringComparison.OrdinalIgnoreCase)) + return null; + + var resetText = string.Empty; + if (response.Headers.TryGetValues("X-RateLimit-Reset", out var resetValues)) + { + var raw = (resetValues ?? Array.Empty()).FirstOrDefault(); + if (long.TryParse(raw, out var epoch)) + { + var resetUtc = DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime; + resetText = $"Rate limit resets at {resetUtc:O} UTC."; + } + } + + return string.IsNullOrWhiteSpace(resetText) + ? "GitHub API rate limit exceeded." + : $"GitHub API rate limit exceeded. {resetText}"; + } + + private static string TrimForMessage(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + var normalized = text!.Trim(); + const int maxLength = 2000; + if (normalized.Length <= maxLength) + return normalized; + + return normalized.Substring(0, maxLength) + "..."; + } + + private static string NormalizeRepository(string? repository) + { + if (string.IsNullOrWhiteSpace(repository)) + return string.Empty; + + var normalized = repository!.Trim().Trim('"').Trim(); + normalized = normalized.Trim('/'); + return normalized; + } + + private static Uri NormalizeApiBaseUri(string? apiBaseUrl) + { + var raw = (apiBaseUrl ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return new Uri("https://api.github.com/", UriKind.Absolute); + + if (!raw.EndsWith("/", StringComparison.Ordinal)) + raw += "/"; + + if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) + throw new InvalidOperationException($"Invalid GitHub API base URL: {apiBaseUrl}"); + + if (!uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Unsupported GitHub API base URL scheme: {uri.Scheme}"); + + return uri; + } + + private static Uri BuildApiUri(Uri apiBaseUri, string repository, string relativePathWithQuery) + { + var repoPath = repository.Trim().Trim('/'); + var relative = $"repos/{repoPath}/{relativePathWithQuery.TrimStart('/')}"; + return new Uri(apiBaseUri, relative); + } + + private static int Clamp(int value, int min, int max) + { + if (value < min) return min; + if (value > max) return max; + return value; + } + + private static HttpClient CreateSharedClient() + { + HttpMessageHandler handler; +#if NETFRAMEWORK + handler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }; +#else + handler = new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + MaxConnectionsPerServer = 16 + }; +#endif + + var client = new HttpClient(handler, disposeHandler: true) + { + Timeout = TimeSpan.FromMinutes(5) + }; + client.DefaultRequestHeaders.UserAgent.Clear(); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PowerForge", "1.0")); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + return client; + } +} diff --git a/PowerForge/Services/RunnerHousekeepingService.cs b/PowerForge/Services/RunnerHousekeepingService.cs new file mode 100644 index 00000000..8a7e0696 --- /dev/null +++ b/PowerForge/Services/RunnerHousekeepingService.cs @@ -0,0 +1,569 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace PowerForge; + +/// +/// Reclaims disk space on GitHub-hosted or self-hosted runners using conservative defaults. +/// +public sealed class RunnerHousekeepingService +{ + private readonly ILogger _logger; + + /// + /// Creates a runner housekeeping service with a logger. + /// + /// Logger used for progress and diagnostics. + public RunnerHousekeepingService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Executes a housekeeping run against the current runner filesystem. + /// + /// Cleanup specification. + /// Run summary with step details and free-space counters. + public RunnerHousekeepingResult Clean(RunnerHousekeepingSpec spec) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var normalized = NormalizeSpec(spec); + var steps = new List(); + var freeBefore = GetFreeBytes(normalized.FreeSpaceProbePath); + var aggressiveApplied = normalized.Aggressive || (normalized.AggressiveThresholdBytes.HasValue && freeBefore < normalized.AggressiveThresholdBytes.Value); + + _logger.Info($"Runner root detected as: {normalized.RunnerRootPath}"); + _logger.Info($"Free disk before cleanup: {FormatGiB(freeBefore)} GiB"); + + if (normalized.RequiredFreeBytes.HasValue) + _logger.Info($"Required free disk after cleanup: {FormatGiB(normalized.RequiredFreeBytes.Value)} GiB"); + + if (normalized.AggressiveThresholdBytes.HasValue) + _logger.Info($"Aggressive cleanup threshold: < {FormatGiB(normalized.AggressiveThresholdBytes.Value)} GiB"); + + if (normalized.CleanDiagnostics) + { + steps.Add(DeleteFilesOlderThan( + id: "diag", + title: "Cleanup runner diagnostics", + rootPath: normalized.DiagnosticsRootPath, + retentionDays: normalized.DiagnosticsRetentionDays, + dryRun: normalized.DryRun)); + } + + if (normalized.CleanRunnerTemp) + { + steps.Add(DeleteDirectoryContents( + id: "runner-temp", + title: "Cleanup runner temp", + rootPath: normalized.RunnerTempPath, + dryRun: normalized.DryRun)); + } + + if (aggressiveApplied) + { + if (normalized.CleanActionsCache) + { + steps.Add(DeleteDirectoriesOlderThan( + id: "actions-cache", + title: "Cleanup action working sets", + rootPath: normalized.ActionsRootPath, + retentionDays: normalized.ActionsRetentionDays, + dryRun: normalized.DryRun, + allowSudo: normalized.AllowSudo)); + } + + if (normalized.CleanToolCache) + { + steps.Add(DeleteDirectoriesOlderThan( + id: "tool-cache", + title: "Cleanup runner tool cache", + rootPath: normalized.ToolCachePath, + retentionDays: normalized.ToolCacheRetentionDays, + dryRun: normalized.DryRun, + allowSudo: normalized.AllowSudo)); + } + + if (normalized.ClearDotNetCaches) + { + steps.Add(RunCommand( + id: "dotnet-cache", + title: "Clear dotnet caches", + fileName: "dotnet", + arguments: new[] { "nuget", "locals", "all", "--clear" }, + workingDirectory: normalized.WorkRootPath, + dryRun: normalized.DryRun)); + } + + if (normalized.PruneDocker) + { + var args = normalized.IncludeDockerVolumes + ? new[] { "system", "prune", "-af", "--volumes" } + : new[] { "system", "prune", "-af" }; + + steps.Add(RunCommand( + id: "docker-prune", + title: "Prune Docker data", + fileName: "docker", + arguments: args, + workingDirectory: normalized.WorkRootPath, + dryRun: normalized.DryRun)); + } + } + else + { + _logger.Info("Skipping aggressive cleanup; free disk is healthy."); + } + + var freeAfter = normalized.DryRun ? freeBefore : GetFreeBytes(normalized.FreeSpaceProbePath); + var result = new RunnerHousekeepingResult + { + RunnerRootPath = normalized.RunnerRootPath, + WorkRootPath = normalized.WorkRootPath, + RunnerTempPath = normalized.RunnerTempPath, + DiagnosticsRootPath = normalized.DiagnosticsRootPath, + ToolCachePath = normalized.ToolCachePath, + FreeBytesBefore = freeBefore, + FreeBytesAfter = freeAfter, + RequiredFreeBytes = normalized.RequiredFreeBytes, + AggressiveThresholdBytes = normalized.AggressiveThresholdBytes, + AggressiveApplied = aggressiveApplied, + DryRun = normalized.DryRun, + Steps = steps.ToArray(), + Success = steps.All(s => s.Success || s.Skipped) + }; + + if (!normalized.DryRun) + _logger.Info($"Free disk after cleanup: {FormatGiB(freeAfter)} GiB"); + + if (normalized.RequiredFreeBytes.HasValue && freeAfter < normalized.RequiredFreeBytes.Value) + { + result.Success = false; + result.Message = $"Free disk after cleanup is {FormatGiB(freeAfter)} GiB (required: {FormatGiB(normalized.RequiredFreeBytes.Value)} GiB)."; + } + + return result; + } + + private sealed class NormalizedSpec + { + public string RunnerRootPath { get; set; } = string.Empty; + public string WorkRootPath { get; set; } = string.Empty; + public string RunnerTempPath { get; set; } = string.Empty; + public string? DiagnosticsRootPath { get; set; } + public string? ActionsRootPath { get; set; } + public string? ToolCachePath { get; set; } + public string FreeSpaceProbePath { get; set; } = string.Empty; + public long? RequiredFreeBytes { get; set; } + public long? AggressiveThresholdBytes { get; set; } + public int DiagnosticsRetentionDays { get; set; } + public int ActionsRetentionDays { get; set; } + public int ToolCacheRetentionDays { get; set; } + public bool DryRun { get; set; } + public bool Aggressive { get; set; } + public bool CleanDiagnostics { get; set; } + public bool CleanRunnerTemp { get; set; } + public bool CleanActionsCache { get; set; } + public bool CleanToolCache { get; set; } + public bool ClearDotNetCaches { get; set; } + public bool PruneDocker { get; set; } + public bool IncludeDockerVolumes { get; set; } + public bool AllowSudo { get; set; } + } + + private NormalizedSpec NormalizeSpec(RunnerHousekeepingSpec spec) + { + var runnerTemp = ResolveRunnerTempPath(spec); + if (string.IsNullOrWhiteSpace(runnerTemp) || !Directory.Exists(runnerTemp)) + throw new InvalidOperationException("RUNNER_TEMP is missing or invalid. Provide --runner-temp when running outside GitHub Actions."); + + var workRoot = ResolveWorkRootPath(spec, runnerTemp); + var runnerRoot = ResolveRunnerRootPath(spec, workRoot); + var diagnosticsRoot = ResolvePathOrNull(spec.DiagnosticsRootPath) ?? Path.Combine(runnerRoot, "_diag"); + var actionsRoot = Path.Combine(workRoot, "_actions"); + var toolCache = ResolvePathOrNull(spec.ToolCachePath) + ?? ResolvePathOrNull(Environment.GetEnvironmentVariable("RUNNER_TOOL_CACHE")) + ?? ResolvePathOrNull(Environment.GetEnvironmentVariable("AGENT_TOOLSDIRECTORY")); + + var requiredFreeBytes = spec.MinFreeGb is > 0 ? (long?)ToGiBBytes(spec.MinFreeGb.Value) : null; + var aggressiveThresholdGb = spec.AggressiveThresholdGb + ?? (spec.MinFreeGb is > 0 ? spec.MinFreeGb.Value + 5 : (int?)null); + var aggressiveThresholdBytes = aggressiveThresholdGb is > 0 ? (long?)ToGiBBytes(aggressiveThresholdGb.Value) : null; + + return new NormalizedSpec + { + RunnerRootPath = runnerRoot, + WorkRootPath = workRoot, + RunnerTempPath = runnerTemp, + DiagnosticsRootPath = diagnosticsRoot, + ActionsRootPath = actionsRoot, + ToolCachePath = toolCache, + FreeSpaceProbePath = Directory.Exists(runnerRoot) ? runnerRoot : workRoot, + RequiredFreeBytes = requiredFreeBytes, + AggressiveThresholdBytes = aggressiveThresholdBytes, + DiagnosticsRetentionDays = Math.Max(0, spec.DiagnosticsRetentionDays), + ActionsRetentionDays = Math.Max(0, spec.ActionsRetentionDays), + ToolCacheRetentionDays = Math.Max(0, spec.ToolCacheRetentionDays), + DryRun = spec.DryRun, + Aggressive = spec.Aggressive, + CleanDiagnostics = spec.CleanDiagnostics, + CleanRunnerTemp = spec.CleanRunnerTemp, + CleanActionsCache = spec.CleanActionsCache, + CleanToolCache = spec.CleanToolCache, + ClearDotNetCaches = spec.ClearDotNetCaches, + PruneDocker = spec.PruneDocker, + IncludeDockerVolumes = spec.IncludeDockerVolumes, + AllowSudo = spec.AllowSudo + }; + } + + private static string ResolveRunnerTempPath(RunnerHousekeepingSpec spec) + { + var explicitPath = ResolvePathOrNull(spec.RunnerTempPath); + if (!string.IsNullOrWhiteSpace(explicitPath)) + return explicitPath!; + + return ResolvePathOrNull(Environment.GetEnvironmentVariable("RUNNER_TEMP")) ?? string.Empty; + } + + private static string ResolveWorkRootPath(RunnerHousekeepingSpec spec, string runnerTempPath) + { + var explicitPath = ResolvePathOrNull(spec.WorkRootPath); + if (!string.IsNullOrWhiteSpace(explicitPath)) + return explicitPath!; + + var tempParent = Directory.GetParent(runnerTempPath)?.FullName; + if (!string.IsNullOrWhiteSpace(tempParent) && Directory.Exists(tempParent)) + return tempParent!; + + var workspace = ResolvePathOrNull(Environment.GetEnvironmentVariable("GITHUB_WORKSPACE")); + if (!string.IsNullOrWhiteSpace(workspace)) + { + var repoDir = Directory.GetParent(workspace); + var workRoot = repoDir?.Parent?.FullName; + if (!string.IsNullOrWhiteSpace(workRoot) && Directory.Exists(workRoot)) + return workRoot!; + } + + throw new InvalidOperationException("Unable to determine runner work root. Provide --work-root explicitly."); + } + + private static string ResolveRunnerRootPath(RunnerHousekeepingSpec spec, string workRootPath) + { + var explicitPath = ResolvePathOrNull(spec.RunnerRootPath); + if (!string.IsNullOrWhiteSpace(explicitPath)) + return explicitPath!; + + return Directory.GetParent(workRootPath)?.FullName ?? workRootPath; + } + + private RunnerHousekeepingStepResult DeleteFilesOlderThan(string id, string title, string? rootPath, int retentionDays, bool dryRun) + { + if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath)) + return SkippedStep(id, title, $"Path not found: {rootPath ?? "(null)"}"); + + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + var targets = Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories) + .Where(path => File.GetLastWriteTimeUtc(path) <= cutoff) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return DeleteTargets(id, title, targets, dryRun, allowSudo: false, isDirectory: false); + } + + private RunnerHousekeepingStepResult DeleteDirectoryContents(string id, string title, string? rootPath, bool dryRun) + { + if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath)) + return SkippedStep(id, title, $"Path not found: {rootPath ?? "(null)"}"); + + var targets = Directory.EnumerateFileSystemEntries(rootPath) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return DeleteTargets(id, title, targets, dryRun, allowSudo: false, isDirectory: null); + } + + private RunnerHousekeepingStepResult DeleteDirectoriesOlderThan(string id, string title, string? rootPath, int retentionDays, bool dryRun, bool allowSudo) + { + if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath)) + return SkippedStep(id, title, $"Path not found: {rootPath ?? "(null)"}"); + + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + var targets = Directory.EnumerateDirectories(rootPath, "*", SearchOption.TopDirectoryOnly) + .Where(path => Directory.GetLastWriteTimeUtc(path) <= cutoff) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return DeleteTargets(id, title, targets, dryRun, allowSudo, isDirectory: true); + } + + private RunnerHousekeepingStepResult DeleteTargets(string id, string title, string[] targets, bool dryRun, bool allowSudo, bool? isDirectory) + { + if (targets.Length == 0) + return SkippedStep(id, title, "Nothing to clean."); + + if (dryRun) + { + _logger.Info($"{title}: planned {targets.Length} item(s)."); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + DryRun = true, + EntriesAffected = targets.Length, + Message = $"Planned {targets.Length} item(s).", + Targets = targets + }; + } + + foreach (var target in targets) + { + DeleteTarget(target, allowSudo, isDirectory); + } + + _logger.Info($"{title}: deleted {targets.Length} item(s)."); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + EntriesAffected = targets.Length, + Message = $"Deleted {targets.Length} item(s).", + Targets = targets + }; + } + + private void DeleteTarget(string target, bool allowSudo, bool? isDirectory) + { + try + { + if (isDirectory == true || (isDirectory is null && Directory.Exists(target))) + { + Directory.Delete(target, recursive: true); + return; + } + + if (File.Exists(target)) + File.Delete(target); + } + catch when (allowSudo && CanUseSudo() && (isDirectory == true || Directory.Exists(target))) + { + RunSudoDelete(target); + } + } + + private RunnerHousekeepingStepResult RunCommand(string id, string title, string fileName, IReadOnlyList arguments, string workingDirectory, bool dryRun) + { + if (!CommandExists(fileName)) + return SkippedStep(id, title, $"Command not available: {fileName}"); + + var renderedArgs = string.Join(" ", arguments.Select(QuoteForDisplay)); + var commandText = $"{fileName} {renderedArgs}".Trim(); + + if (dryRun) + { + _logger.Info($"{title}: would run '{commandText}'."); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + DryRun = true, + Command = commandText, + Message = $"Would run '{commandText}'." + }; + } + + var result = RunProcess(fileName, arguments, workingDirectory); + if (result.ExitCode == 0) + { + _logger.Info($"{title}: completed."); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + Command = commandText, + ExitCode = result.ExitCode, + Message = "Completed successfully." + }; + } + + _logger.Warn($"{title}: exit code {result.ExitCode}. {result.StdErr}".Trim()); + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + Success = false, + Command = commandText, + ExitCode = result.ExitCode, + Message = string.IsNullOrWhiteSpace(result.StdErr) ? $"Exit code {result.ExitCode}." : result.StdErr.Trim() + }; + } + + private static RunnerHousekeepingStepResult SkippedStep(string id, string title, string message) + { + return new RunnerHousekeepingStepResult + { + Id = id, + Title = title, + Skipped = true, + Message = message + }; + } + + private static long GetFreeBytes(string path) + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrWhiteSpace(root)) + throw new InvalidOperationException($"Unable to determine filesystem root for path: {path}"); + + var drive = new DriveInfo(root); + return drive.AvailableFreeSpace; + } + + private static long ToGiBBytes(int gibibytes) + => gibibytes <= 0 ? 0 : (long)gibibytes * 1024L * 1024L * 1024L; + + private static long FormatGiB(long bytes) + => bytes <= 0 ? 0 : bytes / 1024L / 1024L / 1024L; + + private static string? ResolvePathOrNull(string? value) + { + var trimmed = value == null ? string.Empty : value.Trim().Trim('"'); + if (string.IsNullOrWhiteSpace(trimmed)) + return null; + + return Path.GetFullPath(trimmed); + } + + private static bool CommandExists(string fileName) + { + var pathValue = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(pathValue)) + return false; + + var extensions = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT;.COM") + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + : new[] { string.Empty }; + + foreach (var rawSegment in pathValue.Split(new[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries)) + { + var segment = rawSegment.Trim(); + if (string.IsNullOrWhiteSpace(segment) || !Directory.Exists(segment)) + continue; + + foreach (var extension in extensions) + { + var candidate = Path.Combine(segment, fileName + extension); + if (File.Exists(candidate)) + return true; + } + } + + return false; + } + + private static bool CanUseSudo() + => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && CommandExists("sudo"); + + private void RunSudoDelete(string target) + { + var result = RunProcess("sudo", new[] { "rm", "-rf", target }, workingDirectory: Path.GetDirectoryName(target) ?? Environment.CurrentDirectory); + if (result.ExitCode != 0) + throw new IOException(string.IsNullOrWhiteSpace(result.StdErr) ? $"sudo rm -rf failed for '{target}'." : result.StdErr.Trim()); + } + + private static (int ExitCode, string StdOut, string StdErr) RunProcess(string fileName, IReadOnlyList arguments, string workingDirectory) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + ProcessStartInfoEncoding.TryApplyUtf8(psi); + +#if NET472 + psi.Arguments = BuildWindowsArgumentString(arguments); +#else + foreach (var arg in arguments) + psi.ArgumentList.Add(arg); +#endif + + using var process = Process.Start(psi); + if (process is null) + return (-1, string.Empty, $"Failed to start process: {fileName}"); + + var stdOut = process.StandardOutput.ReadToEnd(); + var stdErr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, stdOut, stdErr); + } + + private static string QuoteForDisplay(string argument) + { + if (string.IsNullOrWhiteSpace(argument)) + return "\"\""; + + return argument.IndexOfAny(new[] { ' ', '\t', '"' }) >= 0 + ? $"\"{argument.Replace("\"", "\\\"")}\"" + : argument; + } + +#if NET472 + private static string BuildWindowsArgumentString(IEnumerable arguments) + => string.Join(" ", arguments.Select(EscapeWindowsArgument)); + + private static string EscapeWindowsArgument(string arg) + { + if (arg is null) return "\"\""; + if (arg.Length == 0) return "\"\""; + + var needsQuotes = arg.Any(ch => char.IsWhiteSpace(ch) || ch == '"'); + if (!needsQuotes) return arg; + + var sb = new System.Text.StringBuilder(); + sb.Append('"'); + + var backslashCount = 0; + foreach (var ch in arg) + { + if (ch == '\\') + { + backslashCount++; + continue; + } + + if (ch == '"') + { + sb.Append('\\', backslashCount * 2 + 1); + sb.Append('"'); + backslashCount = 0; + continue; + } + + if (backslashCount > 0) + { + sb.Append('\\', backslashCount); + backslashCount = 0; + } + + sb.Append(ch); + } + + if (backslashCount > 0) + sb.Append('\\', backslashCount * 2); + + sb.Append('"'); + return sb.ToString(); + } +#endif +} diff --git a/README.MD b/README.MD index be3b897d..2443114c 100644 --- a/README.MD +++ b/README.MD @@ -143,6 +143,32 @@ powerforge github artifacts prune --name "test-results*,coverage*,github-pages" powerforge github artifacts prune --apply --keep 5 --max-age-days 7 --max-delete 200 ``` +GitHub cache cleanup and runner housekeeping: + +```powershell +# GitHub Actions cache cleanup (dry-run by default) +powerforge github caches prune --key "ubuntu-*,windows-*" --keep 1 --max-age-days 14 + +# Runner cleanup for hosted/self-hosted GitHub Actions runners +powerforge github runner cleanup --apply --min-free-gb 20 +``` + +If you want the shortest workflow possible across repos, use the reusable composite action: + +```yaml +permissions: + contents: read + actions: write + +jobs: + housekeeping: + runs-on: ubuntu-latest + steps: + - uses: EvotecIT/PSPublishModule/.github/actions/github-housekeeping@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + Introduced in **1.0.0** a new way to build PowerShell module based on DSL language. ```powershell From 1d74ff7d0b392d21256be52950c55062ce13e089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 12 Mar 2026 10:06:29 +0100 Subject: [PATCH 2/7] Simplify housekeeping composite action --- .../Invoke-PowerForgeHousekeeping.ps1 | 105 ++++++++++++++++++ .../actions/github-housekeeping/action.yml | 95 ++++------------ 2 files changed, 126 insertions(+), 74 deletions(-) create mode 100644 .github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 diff --git a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 new file mode 100644 index 00000000..213b6c21 --- /dev/null +++ b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 @@ -0,0 +1,105 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('runner', 'caches', 'artifacts')] + [string] $Mode +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path +$project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" + +function Add-ApplyMode { + param([System.Collections.Generic.List[string]] $Arguments) + + if ($env:INPUT_APPLY -eq 'true') { + $null = $Arguments.Add('--apply') + } else { + $null = $Arguments.Add('--dry-run') + } +} + +function Add-OptionalPair { + param( + [System.Collections.Generic.List[string]] $Arguments, + [string] $Option, + [string] $Value + ) + + if (-not [string]::IsNullOrWhiteSpace($Value)) { + $null = $Arguments.Add($Option) + $null = $Arguments.Add($Value) + } +} + +function Resolve-Repository { + if (-not [string]::IsNullOrWhiteSpace($env:INPUT_REPO)) { + return $env:INPUT_REPO + } + + return $env:GITHUB_REPOSITORY +} + +function Resolve-Token { + if (-not [string]::IsNullOrWhiteSpace($env:POWERFORGE_GITHUB_TOKEN)) { + return $env:POWERFORGE_GITHUB_TOKEN + } + + throw 'GitHub token is required for remote GitHub housekeeping.' +} + +$arguments = [System.Collections.Generic.List[string]]::new() +$arguments.AddRange(@('run', '--project', $project, '-c', 'Release', '--no-build', '--')) + +switch ($Mode) { + 'runner' { + $arguments.AddRange(@('github', 'runner', 'cleanup')) + Add-ApplyMode -Arguments $arguments + Add-OptionalPair -Arguments $arguments -Option '--min-free-gb' -Value $env:INPUT_MIN_FREE_GB + Add-OptionalPair -Arguments $arguments -Option '--aggressive-threshold-gb' -Value $env:INPUT_RUNNER_AGGRESSIVE_THRESHOLD_GB + + if ($env:INPUT_ALLOW_SUDO -eq 'true') { + $null = $arguments.Add('--allow-sudo') + } + } + 'caches' { + $repository = Resolve-Repository + $token = Resolve-Token + + $arguments.AddRange(@( + 'github', 'caches', 'prune', + '--repo', $repository, + '--token', $token, + '--keep', $env:INPUT_CACHE_KEEP, + '--max-age-days', $env:INPUT_CACHE_MAX_AGE_DAYS, + '--max-delete', $env:INPUT_CACHE_MAX_DELETE + )) + + Add-ApplyMode -Arguments $arguments + Add-OptionalPair -Arguments $arguments -Option '--key' -Value $env:INPUT_CACHE_KEY + Add-OptionalPair -Arguments $arguments -Option '--exclude' -Value $env:INPUT_CACHE_EXCLUDE + } + 'artifacts' { + $repository = Resolve-Repository + $token = Resolve-Token + + $arguments.AddRange(@( + 'github', 'artifacts', 'prune', + '--repo', $repository, + '--token', $token, + '--keep', $env:INPUT_ARTIFACT_KEEP, + '--max-age-days', $env:INPUT_ARTIFACT_MAX_AGE_DAYS, + '--max-delete', $env:INPUT_ARTIFACT_MAX_DELETE + )) + + Add-ApplyMode -Arguments $arguments + Add-OptionalPair -Arguments $arguments -Option '--name' -Value $env:INPUT_ARTIFACT_NAME + Add-OptionalPair -Arguments $arguments -Option '--exclude' -Value $env:INPUT_ARTIFACT_EXCLUDE + } +} + +dotnet $arguments +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml index 89d18443..034ee93b 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -100,96 +100,43 @@ runs: - name: Cleanup runner working sets if: ${{ inputs.cleanup-runner == 'true' }} shell: pwsh - 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', '--', - 'github', 'runner', 'cleanup' - ) - - if ('${{ inputs.apply }}' -eq 'true') { $args += '--apply' } else { $args += '--dry-run' } - if ('${{ inputs.min-free-gb }}') { $args += @('--min-free-gb', '${{ inputs.min-free-gb }}') } - if ('${{ inputs.runner-aggressive-threshold-gb }}') { $args += @('--aggressive-threshold-gb', '${{ inputs.runner-aggressive-threshold-gb }}') } - if ('${{ inputs.allow-sudo }}' -eq 'true') { $args += '--allow-sudo' } - - dotnet @args - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 -Mode runner env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: 1 + INPUT_APPLY: ${{ inputs.apply }} + INPUT_MIN_FREE_GB: ${{ inputs.min-free-gb }} + INPUT_RUNNER_AGGRESSIVE_THRESHOLD_GB: ${{ inputs.runner-aggressive-threshold-gb }} + INPUT_ALLOW_SUDO: ${{ inputs.allow-sudo }} - name: Cleanup GitHub caches if: ${{ inputs.cleanup-caches == 'true' }} shell: pwsh - run: | - $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path - $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" - - $repository = '${{ inputs.repo }}' - if ([string]::IsNullOrWhiteSpace($repository)) { $repository = '${{ github.repository }}' } - - $token = $env:POWERFORGE_GITHUB_TOKEN - if ([string]::IsNullOrWhiteSpace($token)) { - Write-Error 'GitHub token is required for cache cleanup.' - exit 1 - } - - $args = @( - 'run', '--project', $project, '-c', 'Release', '--no-build', '--', - 'github', 'caches', 'prune', - '--repo', $repository, - '--token', $token, - '--keep', '${{ inputs.cache-keep }}', - '--max-age-days', '${{ inputs.cache-max-age-days }}', - '--max-delete', '${{ inputs.cache-max-delete }}' - ) - - if ('${{ inputs.apply }}' -eq 'true') { $args += '--apply' } else { $args += '--dry-run' } - if ('${{ inputs.cache-key }}') { $args += @('--key', '${{ inputs.cache-key }}') } - if ('${{ inputs.cache-exclude }}') { $args += @('--exclude', '${{ inputs.cache-exclude }}') } - - dotnet @args - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 -Mode caches env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: 1 + INPUT_APPLY: ${{ inputs.apply }} + INPUT_REPO: ${{ inputs.repo }} + INPUT_CACHE_KEEP: ${{ inputs.cache-keep }} + INPUT_CACHE_MAX_AGE_DAYS: ${{ inputs.cache-max-age-days }} + INPUT_CACHE_MAX_DELETE: ${{ inputs.cache-max-delete }} + INPUT_CACHE_KEY: ${{ inputs.cache-key }} + INPUT_CACHE_EXCLUDE: ${{ inputs.cache-exclude }} POWERFORGE_GITHUB_TOKEN: ${{ inputs.github-token != '' && inputs.github-token || github.token }} - name: Cleanup GitHub artifacts if: ${{ inputs.cleanup-artifacts == 'true' }} shell: pwsh - run: | - $repoRoot = (Resolve-Path (Join-Path $env:GITHUB_ACTION_PATH "../..")).Path - $project = Join-Path $repoRoot "PowerForge.Cli/PowerForge.Cli.csproj" - - $repository = '${{ inputs.repo }}' - if ([string]::IsNullOrWhiteSpace($repository)) { $repository = '${{ github.repository }}' } - - $token = $env:POWERFORGE_GITHUB_TOKEN - if ([string]::IsNullOrWhiteSpace($token)) { - Write-Error 'GitHub token is required for artifact cleanup.' - exit 1 - } - - $args = @( - 'run', '--project', $project, '-c', 'Release', '--no-build', '--', - 'github', 'artifacts', 'prune', - '--repo', $repository, - '--token', $token, - '--keep', '${{ inputs.artifact-keep }}', - '--max-age-days', '${{ inputs.artifact-max-age-days }}', - '--max-delete', '${{ inputs.artifact-max-delete }}' - ) - - if ('${{ inputs.apply }}' -eq 'true') { $args += '--apply' } else { $args += '--dry-run' } - if ('${{ inputs.artifact-name }}') { $args += @('--name', '${{ inputs.artifact-name }}') } - if ('${{ inputs.artifact-exclude }}') { $args += @('--exclude', '${{ inputs.artifact-exclude }}') } - - dotnet @args - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 -Mode artifacts env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: 1 + INPUT_APPLY: ${{ inputs.apply }} + INPUT_REPO: ${{ inputs.repo }} + INPUT_ARTIFACT_KEEP: ${{ inputs.artifact-keep }} + INPUT_ARTIFACT_MAX_AGE_DAYS: ${{ inputs.artifact-max-age-days }} + INPUT_ARTIFACT_MAX_DELETE: ${{ inputs.artifact-max-delete }} + INPUT_ARTIFACT_NAME: ${{ inputs.artifact-name }} + INPUT_ARTIFACT_EXCLUDE: ${{ inputs.artifact-exclude }} POWERFORGE_GITHUB_TOKEN: ${{ inputs.github-token != '' && inputs.github-token || github.token }} From b76fc9af01e20c4872e81ae73125be3a66e6b9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 12 Mar 2026 10:34:37 +0100 Subject: [PATCH 3/7] Add housekeeping workflow summaries --- .../Invoke-PowerForgeHousekeeping.ps1 | 143 +++++++++++++++++- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 index 213b6c21..645aa645 100644 --- a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 +++ b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 @@ -10,6 +10,26 @@ $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 Add-ApplyMode { param([System.Collections.Generic.List[string]] $Arguments) @@ -49,6 +69,96 @@ function Resolve-Token { throw 'GitHub token is required for remote GitHub housekeeping.' } +function Write-EnvelopeSummary { + param( + [string] $CurrentMode, + [pscustomobject] $Envelope + ) + + if (-not $Envelope.result) { + return + } + + $result = $Envelope.result + switch ($CurrentMode) { + 'runner' { + $lines = @( + "### Runner housekeeping", + "", + "- Mode: $(if ($result.dryRun) { 'dry-run' } else { 'apply' })", + "- Free before: $(Format-GiB ([long]$result.freeBytesBefore))", + "- Free after: $(Format-GiB ([long]$result.freeBytesAfter))", + "- Aggressive cleanup: $(if ($result.aggressiveApplied) { 'yes' } else { 'no' })", + "- Success: $(if ($Envelope.success) { 'yes' } else { 'no' })" + ) + + if ($result.message) { + $lines += "- Message: $($result.message)" + } + + Write-Host ("Runner housekeeping: free {0} -> {1}; aggressive={2}" -f ` + (Format-GiB ([long]$result.freeBytesBefore)), ` + (Format-GiB ([long]$result.freeBytesAfter)), ` + $(if ($result.aggressiveApplied) { 'yes' } else { 'no' })) + + Write-MarkdownSummary -Lines ($lines + '') + } + 'caches' { + $usageLine = $null + if ($result.usageBefore) { + $usageLine = "- Usage before: $($result.usageBefore.activeCachesCount) caches, $(Format-GiB ([long]$result.usageBefore.activeCachesSizeInBytes))" + } + + $lines = @( + "### GitHub caches", + "", + "- Mode: $(if ($result.dryRun) { 'dry-run' } else { 'apply' })", + "- Scanned: $($result.scannedCaches)", + "- Matched: $($result.matchedCaches)", + "- Planned deletes: $($result.plannedDeletes) ($(Format-GiB ([long]$result.plannedDeleteBytes)))", + "- Deleted: $($result.deletedCaches) ($(Format-GiB ([long]$result.deletedBytes)))", + "- Failed deletes: $($result.failedDeletes)", + "- Success: $(if ($Envelope.success) { 'yes' } else { 'no' })" + ) + + if ($usageLine) { + $lines = @($lines[0], $lines[1], $usageLine) + $lines[2..($lines.Length - 1)] + } + + if ($result.message) { + $lines += "- Message: $($result.message)" + } + + Write-Host ("GitHub caches: scanned={0}, planned={1}, deleted={2}, failed={3}" -f ` + $result.scannedCaches, $result.plannedDeletes, $result.deletedCaches, $result.failedDeletes) + + Write-MarkdownSummary -Lines ($lines + '') + } + 'artifacts' { + $lines = @( + "### GitHub artifacts", + "", + "- Mode: $(if ($result.dryRun) { 'dry-run' } else { 'apply' })", + "- Scanned: $($result.scannedArtifacts)", + "- Matched: $($result.matchedArtifacts)", + "- Planned deletes: $($result.plannedDeletes) ($(Format-GiB ([long]$result.plannedDeleteBytes)))", + "- Deleted: $($result.deletedArtifacts) ($(Format-GiB ([long]$result.deletedBytes)))", + "- Failed deletes: $($result.failedDeletes)", + "- Success: $(if ($Envelope.success) { 'yes' } else { 'no' })" + ) + + if ($result.message) { + $lines += "- Message: $($result.message)" + } + + Write-Host ("GitHub artifacts: scanned={0}, planned={1}, deleted={2}, failed={3}" -f ` + $result.scannedArtifacts, $result.plannedDeletes, $result.deletedArtifacts, $result.failedDeletes) + + Write-MarkdownSummary -Lines ($lines + '') + } + } +} + $arguments = [System.Collections.Generic.List[string]]::new() $arguments.AddRange(@('run', '--project', $project, '-c', 'Release', '--no-build', '--')) @@ -99,7 +209,34 @@ switch ($Mode) { } } -dotnet $arguments -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE +$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 command failed for mode '$Mode' with exit code $exitCode and produced no output." + } + + return +} + +try { + $envelope = $rawOutput | ConvertFrom-Json -Depth 20 +} catch { + Write-Host $rawOutput + throw +} + +Write-EnvelopeSummary -CurrentMode $Mode -Envelope $envelope + +if (-not $envelope.success) { + Write-Host $rawOutput + if ($envelope.exitCode) { + exit [int]$envelope.exitCode + } + + exit 1 } From bbf70fcadad5a2fc28fd3749685d35baf81d3e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 12 Mar 2026 11:22:51 +0100 Subject: [PATCH 4/7] Refactor GitHub housekeeping and project build --- .../Invoke-PowerForgeHousekeeping.ps1 | 245 +++++--------- .github/actions/github-housekeeping/README.md | 36 +-- .../actions/github-housekeeping/action.yml | 118 +------ .github/workflows/github-housekeeping.yml | 28 +- .../reusable-github-housekeeping.yml | 48 +++ .powerforge/github-housekeeping.json | 21 ++ Build/Build-Project.ps1 | 23 ++ Build/project.build.json | 40 +++ Docs/PSPublishModule.ProjectBuild.md | 2 + PowerForge.Cli/PowerForge.Cli.csproj | 8 + PowerForge.Cli/PowerForgeCliJsonContext.cs | 2 + PowerForge.Cli/Program.Command.GitHub.cs | 135 ++++++++ PowerForge.Cli/Program.Helpers.IOAndJson.cs | 47 +++ .../PowerForge.PowerShell.csproj | 8 + .../GitHubHousekeepingServiceTests.cs | 236 ++++++++++++++ PowerForge.Tests/packages.lock.json | 2 +- PowerForge.Web.Cli/PowerForge.Web.Cli.csproj | 8 + PowerForge.Web/PowerForge.Web.csproj | 10 + .../Models/DotNetRepositoryReleaseResult.cs | 3 + .../Models/GitHubActionsCacheCleanup.cs | 5 + PowerForge/Models/GitHubHousekeeping.cs | 303 ++++++++++++++++++ PowerForge/PowerForge.csproj | 8 + .../DotNetRepositoryReleaseService.Execute.cs | 30 +- ...RepositoryReleaseService.ExpectedAndZip.cs | 56 ++-- ...ositoryReleaseService.ValidationAndSort.cs | 2 +- ...ositoryReleaseService.VersionAndPacking.cs | 13 +- .../GitHubActionsCacheCleanupService.cs | 1 + .../Services/GitHubHousekeepingService.cs | 194 +++++++++++ README.MD | 53 ++- Schemas/github.housekeeping.schema.json | 150 +++++++++ 30 files changed, 1481 insertions(+), 354 deletions(-) create mode 100644 .github/workflows/reusable-github-housekeeping.yml create mode 100644 .powerforge/github-housekeeping.json create mode 100644 Build/Build-Project.ps1 create mode 100644 Build/project.build.json create mode 100644 PowerForge.Tests/GitHubHousekeepingServiceTests.cs create mode 100644 PowerForge/Models/GitHubHousekeeping.cs create mode 100644 PowerForge/Services/GitHubHousekeepingService.cs create mode 100644 Schemas/github.housekeeping.schema.json diff --git a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 index 645aa645..707b2e69 100644 --- a/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 +++ b/.github/actions/github-housekeeping/Invoke-PowerForgeHousekeeping.ps1 @@ -1,9 +1,5 @@ [CmdletBinding()] -param( - [Parameter(Mandatory)] - [ValidateSet('runner', 'caches', 'artifacts')] - [string] $Mode -) +param() $ErrorActionPreference = 'Stop' @@ -30,183 +26,104 @@ function Write-MarkdownSummary { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ($Lines -join [Environment]::NewLine) } -function Add-ApplyMode { - param([System.Collections.Generic.List[string]] $Arguments) - - if ($env:INPUT_APPLY -eq 'true') { - $null = $Arguments.Add('--apply') - } else { - $null = $Arguments.Add('--dry-run') +function Resolve-ConfigPath { + $configPath = $env:INPUT_CONFIG_PATH + if ([string]::IsNullOrWhiteSpace($configPath)) { + $configPath = '.powerforge/github-housekeeping.json' } -} - -function Add-OptionalPair { - param( - [System.Collections.Generic.List[string]] $Arguments, - [string] $Option, - [string] $Value - ) - if (-not [string]::IsNullOrWhiteSpace($Value)) { - $null = $Arguments.Add($Option) - $null = $Arguments.Add($Value) + if ([System.IO.Path]::IsPathRooted($configPath)) { + return [System.IO.Path]::GetFullPath($configPath) } -} -function Resolve-Repository { - if (-not [string]::IsNullOrWhiteSpace($env:INPUT_REPO)) { - return $env:INPUT_REPO + if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { + throw 'GITHUB_WORKSPACE is not set.' } - return $env:GITHUB_REPOSITORY + return [System.IO.Path]::GetFullPath((Join-Path $env:GITHUB_WORKSPACE $configPath)) } -function Resolve-Token { - if (-not [string]::IsNullOrWhiteSpace($env:POWERFORGE_GITHUB_TOKEN)) { - return $env:POWERFORGE_GITHUB_TOKEN - } - - throw 'GitHub token is required for remote GitHub housekeeping.' -} - -function Write-EnvelopeSummary { - param( - [string] $CurrentMode, - [pscustomobject] $Envelope - ) +function Write-HousekeepingSummary { + param([pscustomobject] $Envelope) if (-not $Envelope.result) { return } $result = $Envelope.result - switch ($CurrentMode) { - 'runner' { - $lines = @( - "### Runner housekeeping", - "", - "- Mode: $(if ($result.dryRun) { 'dry-run' } else { 'apply' })", - "- Free before: $(Format-GiB ([long]$result.freeBytesBefore))", - "- Free after: $(Format-GiB ([long]$result.freeBytesAfter))", - "- Aggressive cleanup: $(if ($result.aggressiveApplied) { 'yes' } else { 'no' })", - "- Success: $(if ($Envelope.success) { 'yes' } else { 'no' })" - ) - - if ($result.message) { - $lines += "- Message: $($result.message)" - } - - Write-Host ("Runner housekeeping: free {0} -> {1}; aggressive={2}" -f ` - (Format-GiB ([long]$result.freeBytesBefore)), ` - (Format-GiB ([long]$result.freeBytesAfter)), ` - $(if ($result.aggressiveApplied) { 'yes' } else { 'no' })) - - Write-MarkdownSummary -Lines ($lines + '') - } - 'caches' { - $usageLine = $null - if ($result.usageBefore) { - $usageLine = "- Usage before: $($result.usageBefore.activeCachesCount) caches, $(Format-GiB ([long]$result.usageBefore.activeCachesSizeInBytes))" - } - - $lines = @( - "### GitHub caches", - "", - "- Mode: $(if ($result.dryRun) { 'dry-run' } else { 'apply' })", - "- Scanned: $($result.scannedCaches)", - "- Matched: $($result.matchedCaches)", - "- Planned deletes: $($result.plannedDeletes) ($(Format-GiB ([long]$result.plannedDeleteBytes)))", - "- Deleted: $($result.deletedCaches) ($(Format-GiB ([long]$result.deletedBytes)))", - "- Failed deletes: $($result.failedDeletes)", - "- Success: $(if ($Envelope.success) { 'yes' } else { 'no' })" - ) - - if ($usageLine) { - $lines = @($lines[0], $lines[1], $usageLine) + $lines[2..($lines.Length - 1)] - } - - if ($result.message) { - $lines += "- Message: $($result.message)" - } - - Write-Host ("GitHub caches: scanned={0}, planned={1}, deleted={2}, failed={3}" -f ` - $result.scannedCaches, $result.plannedDeletes, $result.deletedCaches, $result.failedDeletes) - - Write-MarkdownSummary -Lines ($lines + '') + $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))" } - 'artifacts' { - $lines = @( - "### GitHub artifacts", - "", - "- Mode: $(if ($result.dryRun) { 'dry-run' } else { 'apply' })", - "- Scanned: $($result.scannedArtifacts)", - "- Matched: $($result.matchedArtifacts)", - "- Planned deletes: $($result.plannedDeletes) ($(Format-GiB ([long]$result.plannedDeleteBytes)))", - "- Deleted: $($result.deletedArtifacts) ($(Format-GiB ([long]$result.deletedBytes)))", - "- Failed deletes: $($result.failedDeletes)", - "- Success: $(if ($Envelope.success) { 'yes' } else { 'no' })" - ) - - if ($result.message) { - $lines += "- Message: $($result.message)" - } - - Write-Host ("GitHub artifacts: scanned={0}, planned={1}, deleted={2}, failed={3}" -f ` - $result.scannedArtifacts, $result.plannedDeletes, $result.deletedArtifacts, $result.failedDeletes) - - Write-MarkdownSummary -Lines ($lines + '') + 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 + '') } -$arguments = [System.Collections.Generic.List[string]]::new() -$arguments.AddRange(@('run', '--project', $project, '-c', 'Release', '--no-build', '--')) +$configPath = Resolve-ConfigPath +if (-not (Test-Path -LiteralPath $configPath)) { + throw "Housekeeping config not found: $configPath" +} -switch ($Mode) { - 'runner' { - $arguments.AddRange(@('github', 'runner', 'cleanup')) - Add-ApplyMode -Arguments $arguments - Add-OptionalPair -Arguments $arguments -Option '--min-free-gb' -Value $env:INPUT_MIN_FREE_GB - Add-OptionalPair -Arguments $arguments -Option '--aggressive-threshold-gb' -Value $env:INPUT_RUNNER_AGGRESSIVE_THRESHOLD_GB +$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 ($env:INPUT_ALLOW_SUDO -eq 'true') { - $null = $arguments.Add('--allow-sudo') - } - } - 'caches' { - $repository = Resolve-Repository - $token = Resolve-Token - - $arguments.AddRange(@( - 'github', 'caches', 'prune', - '--repo', $repository, - '--token', $token, - '--keep', $env:INPUT_CACHE_KEEP, - '--max-age-days', $env:INPUT_CACHE_MAX_AGE_DAYS, - '--max-delete', $env:INPUT_CACHE_MAX_DELETE - )) - - Add-ApplyMode -Arguments $arguments - Add-OptionalPair -Arguments $arguments -Option '--key' -Value $env:INPUT_CACHE_KEY - Add-OptionalPair -Arguments $arguments -Option '--exclude' -Value $env:INPUT_CACHE_EXCLUDE - } - 'artifacts' { - $repository = Resolve-Repository - $token = Resolve-Token - - $arguments.AddRange(@( - 'github', 'artifacts', 'prune', - '--repo', $repository, - '--token', $token, - '--keep', $env:INPUT_ARTIFACT_KEEP, - '--max-age-days', $env:INPUT_ARTIFACT_MAX_AGE_DAYS, - '--max-delete', $env:INPUT_ARTIFACT_MAX_DELETE - )) - - Add-ApplyMode -Arguments $arguments - Add-OptionalPair -Arguments $arguments -Option '--name' -Value $env:INPUT_ARTIFACT_NAME - Add-OptionalPair -Arguments $arguments -Option '--exclude' -Value $env:INPUT_ARTIFACT_EXCLUDE - } +if (-not [string]::IsNullOrWhiteSpace($env:POWERFORGE_GITHUB_TOKEN)) { + $null = $arguments.Add('--token') + $null = $arguments.Add($env:POWERFORGE_GITHUB_TOKEN) } $null = $arguments.Add('--output') @@ -217,20 +134,20 @@ $exitCode = $LASTEXITCODE if ([string]::IsNullOrWhiteSpace($rawOutput)) { if ($exitCode -ne 0) { - throw "PowerForge command failed for mode '$Mode' with exit code $exitCode and produced no output." + throw "PowerForge housekeeping failed with exit code $exitCode and produced no output." } return } try { - $envelope = $rawOutput | ConvertFrom-Json -Depth 20 + $envelope = $rawOutput | ConvertFrom-Json -Depth 30 } catch { Write-Host $rawOutput throw } -Write-EnvelopeSummary -CurrentMode $Mode -Envelope $envelope +Write-HousekeepingSummary -Envelope $envelope if (-not $envelope.success) { Write-Host $rawOutput diff --git a/.github/actions/github-housekeeping/README.md b/.github/actions/github-housekeeping/README.md index 769d7459..fb82c154 100644 --- a/.github/actions/github-housekeeping/README.md +++ b/.github/actions/github-housekeeping/README.md @@ -1,15 +1,16 @@ # PowerForge GitHub Housekeeping -Reusable composite action that wraps the new C# housekeeping commands from `PowerForge.Cli`. +Reusable composite action that runs the config-driven `powerforge github housekeeping` command from `PowerForge.Cli`. ## What it does -- Cleans runner working sets (`powerforge github runner cleanup`) -- Prunes GitHub Actions caches (`powerforge github caches prune`) -- Prunes GitHub Actions artifacts (`powerforge github artifacts prune`) -- Builds the CLI from this repository, so other repos can consume the action with one `uses:` step +- 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 -## Minimal usage +## Recommended usage + +Use the reusable workflow for the leanest repo wiring: ```yaml permissions: @@ -18,14 +19,13 @@ permissions: jobs: housekeeping: - runs-on: ubuntu-latest - steps: - - uses: EvotecIT/PSPublishModule/.github/actions/github-housekeeping@main - with: - github-token: ${{ secrets.GITHUB_TOKEN }} + uses: EvotecIT/PSPublishModule/.github/workflows/reusable-github-housekeeping.yml@main + with: + config-path: ./.powerforge/github-housekeeping.json + secrets: inherit ``` -## Typical self-hosted usage +## Direct action usage ```yaml permissions: @@ -34,19 +34,17 @@ permissions: jobs: housekeeping: - runs-on: [self-hosted, ubuntu] + runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 - uses: EvotecIT/PSPublishModule/.github/actions/github-housekeeping@main with: + config-path: ./.powerforge/github-housekeeping.json github-token: ${{ secrets.GITHUB_TOKEN }} - min-free-gb: "20" - cache-max-age-days: "14" - cache-max-delete: "200" ``` ## Notes -- Cache deletion needs `actions: write`. +- Cache and artifact deletion need `actions: write`. - Set `apply: "false"` to preview without deleting anything. -- Set `cleanup-runner: "false"` if you only want remote GitHub storage cleanup. -- Set `cleanup-caches: "false"` or `cleanup-artifacts: "false"` to narrow what gets pruned. +- Hosted-runner repos should usually keep `runner.enabled` set to `false` in config. diff --git a/.github/actions/github-housekeeping/action.yml b/.github/actions/github-housekeeping/action.yml index 034ee93b..5ba2daae 100644 --- a/.github/actions/github-housekeeping/action.yml +++ b/.github/actions/github-housekeeping/action.yml @@ -1,81 +1,17 @@ name: PowerForge GitHub Housekeeping -description: Clean GitHub Actions caches and runner working sets using PowerForge.Cli. +description: Run config-driven GitHub housekeeping using PowerForge.Cli. inputs: + config-path: + description: Path to the housekeeping config file inside the checked-out repository. + required: false + default: ".powerforge/github-housekeeping.json" apply: description: When true, apply deletions. When false, run in dry-run mode. required: false default: "true" - cleanup-runner: - description: When true, clean local runner working sets. - required: false - default: "true" - cleanup-artifacts: - description: When true, prune GitHub Actions artifacts for the repository. - required: false - default: "true" - cleanup-caches: - description: When true, prune GitHub Actions caches for the repository. - required: false - default: "true" - repo: - description: Repository in owner/repo format. Defaults to the current repository. - required: false - default: "" github-token: - description: Token used for GitHub cache cleanup. Defaults to github.token. - required: false - default: "" - min-free-gb: - description: Minimum free disk required after runner cleanup. - required: false - default: "20" - runner-aggressive-threshold-gb: - description: Free disk threshold below which aggressive runner cleanup is enabled. - required: false - default: "" - allow-sudo: - description: Allow sudo for deleting protected directories on Unix runners. - required: false - default: "false" - cache-keep: - description: Number of newest caches to keep per cache key. - required: false - default: "1" - cache-max-age-days: - description: Minimum cache age before deletion is allowed. - required: false - default: "14" - cache-max-delete: - description: Maximum number of caches to delete in one run. - required: false - default: "200" - cache-key: - description: Optional cache key include patterns (comma-separated). - required: false - default: "" - cache-exclude: - description: Optional cache key exclude patterns (comma-separated). - required: false - default: "" - artifact-keep: - description: Number of newest artifacts to keep per artifact name. - required: false - default: "5" - artifact-max-age-days: - description: Minimum artifact age before deletion is allowed. - required: false - default: "7" - artifact-max-delete: - description: Maximum number of artifacts to delete in one run. - required: false - default: "200" - artifact-name: - description: Optional artifact include patterns (comma-separated). - required: false - default: "" - artifact-exclude: - description: Optional artifact exclude patterns (comma-separated). + description: Optional token override for remote GitHub cleanup. required: false default: "" @@ -97,46 +33,12 @@ runs: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: 1 - - name: Cleanup runner working sets - if: ${{ inputs.cleanup-runner == 'true' }} - shell: pwsh - run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 -Mode runner - env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - INPUT_APPLY: ${{ inputs.apply }} - INPUT_MIN_FREE_GB: ${{ inputs.min-free-gb }} - INPUT_RUNNER_AGGRESSIVE_THRESHOLD_GB: ${{ inputs.runner-aggressive-threshold-gb }} - INPUT_ALLOW_SUDO: ${{ inputs.allow-sudo }} - - - name: Cleanup GitHub caches - if: ${{ inputs.cleanup-caches == 'true' }} - shell: pwsh - run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 -Mode caches - env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - INPUT_APPLY: ${{ inputs.apply }} - INPUT_REPO: ${{ inputs.repo }} - INPUT_CACHE_KEEP: ${{ inputs.cache-keep }} - INPUT_CACHE_MAX_AGE_DAYS: ${{ inputs.cache-max-age-days }} - INPUT_CACHE_MAX_DELETE: ${{ inputs.cache-max-delete }} - INPUT_CACHE_KEY: ${{ inputs.cache-key }} - INPUT_CACHE_EXCLUDE: ${{ inputs.cache-exclude }} - POWERFORGE_GITHUB_TOKEN: ${{ inputs.github-token != '' && inputs.github-token || github.token }} - - - name: Cleanup GitHub artifacts - if: ${{ inputs.cleanup-artifacts == 'true' }} + - name: Run GitHub housekeeping shell: pwsh - run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 -Mode artifacts + run: ${{ github.action_path }}/Invoke-PowerForgeHousekeeping.ps1 env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: 1 + INPUT_CONFIG_PATH: ${{ inputs['config-path'] }} INPUT_APPLY: ${{ inputs.apply }} - INPUT_REPO: ${{ inputs.repo }} - INPUT_ARTIFACT_KEEP: ${{ inputs.artifact-keep }} - INPUT_ARTIFACT_MAX_AGE_DAYS: ${{ inputs.artifact-max-age-days }} - INPUT_ARTIFACT_MAX_DELETE: ${{ inputs.artifact-max-delete }} - INPUT_ARTIFACT_NAME: ${{ inputs.artifact-name }} - INPUT_ARTIFACT_EXCLUDE: ${{ inputs.artifact-exclude }} - POWERFORGE_GITHUB_TOKEN: ${{ inputs.github-token != '' && inputs.github-token || github.token }} + 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 3dfc4de5..0f1b16be 100644 --- a/.github/workflows/github-housekeeping.yml +++ b/.github/workflows/github-housekeeping.yml @@ -9,14 +9,6 @@ on: description: 'Apply deletions (true/false)' required: false default: 'true' - artifact_max_age_days: - description: 'Delete artifacts older than N days' - required: false - default: '7' - cache_max_age_days: - description: 'Delete caches older than N days' - required: false - default: '14' permissions: actions: write @@ -28,16 +20,10 @@ concurrency: jobs: housekeeping: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - - name: Run PowerForge housekeeping - uses: ./.github/actions/github-housekeeping - with: - apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply || 'true' }} - github-token: ${{ secrets.GITHUB_TOKEN }} - cleanup-runner: 'false' - artifact-max-age-days: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_max_age_days || '7' }} - cache-max-age-days: ${{ github.event_name == 'workflow_dispatch' && inputs.cache_max_age_days || '14' }} + uses: ./.github/workflows/reusable-github-housekeeping.yml + with: + config-path: ./.powerforge/github-housekeeping.json + apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply == 'true' || github.event_name != 'workflow_dispatch' }} + powerforge-ref: ${{ github.sha }} + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reusable-github-housekeeping.yml b/.github/workflows/reusable-github-housekeeping.yml new file mode 100644 index 00000000..d23217c7 --- /dev/null +++ b/.github/workflows/reusable-github-housekeeping.yml @@ -0,0 +1,48 @@ +name: Reusable GitHub Housekeeping + +on: + workflow_call: + inputs: + config-path: + description: Path to the housekeeping config file in the caller repository. + required: false + default: ".powerforge/github-housekeeping.json" + type: string + apply: + description: Whether the run should apply deletions. + required: false + default: true + type: boolean + powerforge-ref: + description: PSPublishModule ref used to resolve the shared housekeeping action. + required: false + default: "main" + type: string + secrets: + github-token: + required: false + +permissions: + actions: write + contents: read + +jobs: + housekeeping: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Checkout PSPublishModule + uses: actions/checkout@v4 + with: + repository: EvotecIT/PSPublishModule + ref: ${{ inputs['powerforge-ref'] }} + path: .powerforge/pspublishmodule + + - name: Run PowerForge housekeeping + uses: ./.powerforge/pspublishmodule/.github/actions/github-housekeeping + with: + config-path: ${{ inputs['config-path'] }} + apply: ${{ inputs.apply && 'true' || 'false' }} + github-token: ${{ secrets['github-token'] != '' && secrets['github-token'] || github.token }} diff --git a/.powerforge/github-housekeeping.json b/.powerforge/github-housekeeping.json new file mode 100644 index 00000000..18677ea6 --- /dev/null +++ b/.powerforge/github-housekeeping.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/github.housekeeping.schema.json", + "repository": "EvotecIT/PSPublishModule", + "tokenEnvName": "GITHUB_TOKEN", + "dryRun": false, + "artifacts": { + "enabled": true, + "keepLatestPerName": 10, + "maxAgeDays": 7, + "maxDelete": 200 + }, + "caches": { + "enabled": true, + "keepLatestPerKey": 2, + "maxAgeDays": 14, + "maxDelete": 200 + }, + "runner": { + "enabled": false + } +} diff --git a/Build/Build-Project.ps1 b/Build/Build-Project.ps1 new file mode 100644 index 00000000..a4746073 --- /dev/null +++ b/Build/Build-Project.ps1 @@ -0,0 +1,23 @@ +param( + [string] $ConfigPath = "$PSScriptRoot\project.build.json", + [Nullable[bool]] $UpdateVersions, + [Nullable[bool]] $Build, + [Nullable[bool]] $PublishNuget = $false, + [Nullable[bool]] $PublishGitHub = $false, + [Nullable[bool]] $Plan, + [string] $PlanPath +) + +Import-Module PSPublishModule -Force -ErrorAction Stop + +$invokeParams = @{ + ConfigPath = $ConfigPath +} +if ($null -ne $UpdateVersions) { $invokeParams.UpdateVersions = $UpdateVersions } +if ($null -ne $Build) { $invokeParams.Build = $Build } +if ($null -ne $PublishNuget) { $invokeParams.PublishNuget = $PublishNuget } +if ($null -ne $PublishGitHub) { $invokeParams.PublishGitHub = $PublishGitHub } +if ($null -ne $Plan) { $invokeParams.Plan = $Plan } +if ($PlanPath) { $invokeParams.PlanPath = $PlanPath } + +Invoke-ProjectBuild @invokeParams diff --git a/Build/project.build.json b/Build/project.build.json new file mode 100644 index 00000000..e4d9a671 --- /dev/null +++ b/Build/project.build.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/project.build.schema.json", + "RootPath": "..", + "ExpectedVersionMap": { + "PowerForge": "1.0.X", + "PowerForge.PowerShell": "1.0.X", + "PowerForge.Cli": "1.0.X", + "PowerForge.Blazor": "1.0.X", + "PowerForge.Web": "1.0.X", + "PowerForge.Web.Cli": "1.0.X" + }, + "ExpectedVersionMapAsInclude": true, + "ExpectedVersionMapUseWildcards": false, + "Configuration": "Release", + "StagingPath": "Artefacts/ProjectBuild", + "CleanStaging": true, + "PlanOutputPath": "Artefacts/ProjectBuild/project.build.plan.json", + "UpdateVersions": true, + "Build": true, + "PublishNuget": false, + "PublishGitHub": false, + "CreateReleaseZip": true, + "CertificateThumbprint": "483292C9E317AA13B07BB7A96AE9D1A5ED9E7703", + "CertificateStore": "CurrentUser", + "TimeStampServer": "http://timestamp.digicert.com", + "PublishSource": "https://api.nuget.org/v3/index.json", + "PublishApiKeyFilePath": "C:\\Support\\Important\\NugetOrgEvotec.txt", + "SkipDuplicate": true, + "PublishFailFast": true, + "GitHubAccessTokenFilePath": "C:\\Support\\Important\\GithubAPI.txt", + "GitHubUsername": "EvotecIT", + "GitHubRepositoryName": "PSPublishModule", + "GitHubIsPreRelease": false, + "GitHubIncludeProjectNameInTag": false, + "GitHubGenerateReleaseNotes": true, + "GitHubReleaseMode": "Single", + "GitHubPrimaryProject": "PowerForge.Cli", + "GitHubTagTemplate": "{Repo}-v{PrimaryVersion}", + "GitHubReleaseName": "{Repo} {PrimaryVersion}" +} diff --git a/Docs/PSPublishModule.ProjectBuild.md b/Docs/PSPublishModule.ProjectBuild.md index 10ad5ae9..ab83b539 100644 --- a/Docs/PSPublishModule.ProjectBuild.md +++ b/Docs/PSPublishModule.ProjectBuild.md @@ -72,6 +72,8 @@ Staging and outputs - `StagingPath`: root directory for pipeline outputs (recommended). - Packages go to `\packages` when `OutputPath` is not set. - Release zips go to `\releases` when `ReleaseZipOutputPath` is not set. +- When a project defines ``, project-build uses that package identity for NuGet version lookup, + planned `.nupkg` names, and release zip names. Otherwise it falls back to the csproj file name. - `CleanStaging`: if true, deletes the staging directory before a run. - `PlanOutputPath`: optional file path for a JSON plan output. diff --git a/PowerForge.Cli/PowerForge.Cli.csproj b/PowerForge.Cli/PowerForge.Cli.csproj index a1c1573b..50d1a332 100644 --- a/PowerForge.Cli/PowerForge.Cli.csproj +++ b/PowerForge.Cli/PowerForge.Cli.csproj @@ -7,11 +7,19 @@ enable PowerForge.Cli PowerForge.Cli + PowerForge.Build true powerforge PowerForge command-line interface for building and publishing PowerShell modules. 1.0.0 true + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + powershell;build;publishing;automation;cli;tool + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge.Cli/PowerForgeCliJsonContext.cs b/PowerForge.Cli/PowerForgeCliJsonContext.cs index 4818cafd..a1686c61 100644 --- a/PowerForge.Cli/PowerForgeCliJsonContext.cs +++ b/PowerForge.Cli/PowerForgeCliJsonContext.cs @@ -33,6 +33,8 @@ namespace PowerForge.Cli; [JsonSerializable(typeof(DotNetPublishResult))] [JsonSerializable(typeof(DotNetPublishFailure))] [JsonSerializable(typeof(DotNetPublishConfigScaffoldResult))] +[JsonSerializable(typeof(GitHubHousekeepingSpec))] +[JsonSerializable(typeof(GitHubHousekeepingResult))] [JsonSerializable(typeof(GitHubArtifactCleanupResult))] [JsonSerializable(typeof(GitHubActionsCacheCleanupResult))] [JsonSerializable(typeof(RunnerHousekeepingResult))] diff --git a/PowerForge.Cli/Program.Command.GitHub.cs b/PowerForge.Cli/Program.Command.GitHub.cs index 52ebffbb..2b271751 100644 --- a/PowerForge.Cli/Program.Command.GitHub.cs +++ b/PowerForge.Cli/Program.Command.GitHub.cs @@ -2,12 +2,14 @@ using PowerForge.Cli; using System; using System.Collections.Generic; +using System.IO; using System.Linq; internal static partial class Program { private const string GitHubArtifactsPruneUsage = "Usage: powerforge github artifacts prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--name ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json]"; private const string GitHubCachesPruneUsage = "Usage: powerforge github caches prune [--repo ] [--api-base-url ] [--token-env ] [--token ] [--key ] [--exclude ] [--keep ] [--max-age-days ] [--max-delete ] [--dry-run|--apply] [--fail-on-delete-error] [--output json]"; + private const string GitHubHousekeepingUsage = "Usage: powerforge github housekeeping [--config ] [--repo ] [--api-base-url ] [--token-env ] [--token ] [--dry-run|--apply] [--output json]"; private const string GitHubRunnerCleanupUsage = "Usage: powerforge github runner cleanup [--runner-temp ] [--work-root ] [--runner-root ] [--diag-root ] [--tool-cache ] [--min-free-gb ] [--aggressive-threshold-gb ] [--diag-retention-days ] [--actions-retention-days ] [--tool-cache-retention-days ] [--dry-run|--apply] [--aggressive] [--allow-sudo] [--skip-diagnostics] [--skip-runner-temp] [--skip-actions-cache] [--skip-tool-cache] [--skip-dotnet-cache] [--skip-docker] [--no-docker-volumes] [--output json]"; private static int CommandGitHub(string[] filteredArgs, CliOptions cli, ILogger logger) @@ -17,6 +19,7 @@ private static int CommandGitHub(string[] filteredArgs, CliOptions cli, ILogger { Console.WriteLine(GitHubArtifactsPruneUsage); Console.WriteLine(GitHubCachesPruneUsage); + Console.WriteLine(GitHubHousekeepingUsage); Console.WriteLine(GitHubRunnerCleanupUsage); return 2; } @@ -25,6 +28,7 @@ private static int CommandGitHub(string[] filteredArgs, CliOptions cli, ILogger { "artifacts" => CommandGitHubArtifacts(argv.Skip(1).ToArray(), cli, logger), "caches" => CommandGitHubCaches(argv.Skip(1).ToArray(), cli, logger), + "housekeeping" => CommandGitHubHousekeeping(argv.Skip(1).ToArray(), cli, logger), "runner" => CommandGitHubRunner(argv.Skip(1).ToArray(), cli, logger), _ => UnknownGitHubCommand() }; @@ -239,10 +243,76 @@ private static int CommandGitHubRunner(string[] argv, CliOptions cli, ILogger lo } } + private static int CommandGitHubHousekeeping(string[] argv, CliOptions cli, ILogger logger) + { + var outputJson = IsJsonOutput(argv); + if (argv.Length > 0 && IsHelpArg(argv[0])) + { + Console.WriteLine(GitHubHousekeepingUsage); + return 2; + } + + GitHubHousekeepingSpec spec; + try + { + spec = ParseGitHubHousekeepingArgs(argv); + } + catch (Exception ex) + { + return WriteGitHubCommandArgumentError(outputJson, "github.housekeeping", ex.Message, GitHubHousekeepingUsage, logger); + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var service = new GitHubHousekeepingService(cmdLogger); + var statusText = spec.DryRun ? "Planning GitHub housekeeping" : "Running GitHub housekeeping"; + var result = RunWithStatus(outputJson, cli, statusText, () => service.Run(spec)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = "github.housekeeping", + Success = result.Success, + ExitCode = exitCode, + Result = CliJson.SerializeToElement(result, CliJson.Context.GitHubHousekeepingResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; + } + + var mode = result.DryRun ? "Dry run" : "Applied"; + logger.Info($"{mode}: {result.Repository ?? "(runner-only)"}"); + logger.Info($"Requested sections: {string.Join(", ", result.RequestedSections)}"); + if (result.CompletedSections.Length > 0) + logger.Info($"Completed sections: {string.Join(", ", result.CompletedSections)}"); + if (result.FailedSections.Length > 0) + logger.Warn($"Failed sections: {string.Join(", ", result.FailedSections)}"); + + if (result.Caches?.UsageBefore is not null) + logger.Info($"GitHub cache usage before cleanup: {result.Caches.UsageBefore.ActiveCachesCount} caches, {result.Caches.UsageBefore.ActiveCachesSizeInBytes} bytes"); + if (result.Caches?.UsageAfter is not null) + logger.Info($"GitHub cache usage after cleanup: {result.Caches.UsageAfter.ActiveCachesCount} caches, {result.Caches.UsageAfter.ActiveCachesSizeInBytes} bytes"); + + if (!string.IsNullOrWhiteSpace(result.Message)) + logger.Warn(result.Message!); + + return exitCode; + } + catch (Exception ex) + { + return WriteGitHubCommandFailure(outputJson, "github.housekeeping", ex.Message, logger); + } + } + private static int UnknownGitHubCommand() { Console.WriteLine(GitHubArtifactsPruneUsage); Console.WriteLine(GitHubCachesPruneUsage); + Console.WriteLine(GitHubHousekeepingUsage); Console.WriteLine(GitHubRunnerCleanupUsage); return 2; } @@ -456,6 +526,71 @@ private static GitHubActionsCacheCleanupSpec ParseGitHubCachesPruneArgs(string[] return spec; } + private static GitHubHousekeepingSpec ParseGitHubHousekeepingArgs(string[] argv) + { + var configPath = TryGetOptionValue(argv, "--config"); + if (string.IsNullOrWhiteSpace(configPath)) + configPath = FindDefaultGitHubHousekeepingConfig(Directory.GetCurrentDirectory()); + if (string.IsNullOrWhiteSpace(configPath)) + throw new InvalidOperationException("Housekeeping config file not found. Provide --config or add .powerforge/github-housekeeping.json."); + + var (spec, fullConfigPath) = LoadGitHubHousekeepingSpecWithPath(configPath!); + ResolveGitHubHousekeepingSpecPaths(spec, fullConfigPath); + + var tokenEnv = string.IsNullOrWhiteSpace(spec.TokenEnvName) ? "GITHUB_TOKEN" : spec.TokenEnvName.Trim(); + + for (var i = 0; i < argv.Length; i++) + { + var arg = argv[i]; + switch (arg.ToLowerInvariant()) + { + case "--config": + i++; + break; + case "--repo": + case "--repository": + spec.Repository = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--token": + spec.Token = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--token-env": + tokenEnv = ++i < argv.Length ? argv[i] : tokenEnv; + break; + case "--api-base-url": + case "--api-url": + spec.ApiBaseUrl = ++i < argv.Length ? argv[i] : string.Empty; + break; + case "--dry-run": + spec.DryRun = true; + break; + case "--apply": + spec.DryRun = false; + break; + case "--output": + i++; + break; + case "--output-json": + case "--json": + break; + default: + ThrowOnUnknownOption(arg); + break; + } + } + + if ((spec.Artifacts?.Enabled ?? false) || (spec.Caches?.Enabled ?? false)) + { + if (string.IsNullOrWhiteSpace(spec.Repository)) + spec.Repository = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY")?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(spec.Token) && !string.IsNullOrWhiteSpace(tokenEnv)) + spec.Token = Environment.GetEnvironmentVariable(tokenEnv)?.Trim() ?? string.Empty; + } + + spec.TokenEnvName = tokenEnv; + return spec; + } + private static RunnerHousekeepingSpec ParseGitHubRunnerCleanupArgs(string[] argv) { var spec = new RunnerHousekeepingSpec(); diff --git a/PowerForge.Cli/Program.Helpers.IOAndJson.cs b/PowerForge.Cli/Program.Helpers.IOAndJson.cs index 2111236f..b3e12b73 100644 --- a/PowerForge.Cli/Program.Helpers.IOAndJson.cs +++ b/PowerForge.Cli/Program.Helpers.IOAndJson.cs @@ -156,6 +156,31 @@ static void ResolvePipelineSpecPaths(ModulePipelineSpec spec, string configFullP return null; } + static string? FindDefaultGitHubHousekeepingConfig(string baseDir) + { + var candidates = new[] + { + "github-housekeeping.json", + Path.Combine(".powerforge", "github-housekeeping.json"), + Path.Combine(".github", "powerforge", "github-housekeeping.json"), + }; + + foreach (var dir in EnumerateSelfAndParents(baseDir)) + { + foreach (var rel in candidates) + { + try + { + var full = Path.GetFullPath(Path.Combine(dir, rel)); + if (File.Exists(full)) return full; + } + catch { /* ignore */ } + } + } + + return null; + } + static IEnumerable EnumerateSelfAndParents(string? baseDir) { string current; @@ -238,6 +263,14 @@ static string ResolveExistingFilePath(string path) return (spec, full); } + static (GitHubHousekeepingSpec Value, string FullPath) LoadGitHubHousekeepingSpecWithPath(string path) + { + var full = ResolveExistingFilePath(path); + var json = File.ReadAllText(full); + var spec = CliJson.DeserializeOrThrow(json, CliJson.Context.GitHubHousekeepingSpec, full); + return (spec, full); + } + static (ModuleInstallSpec Value, string FullPath) LoadInstallSpecWithPath(string path) { var full = ResolveExistingFilePath(path); @@ -254,6 +287,20 @@ static string ResolveExistingFilePath(string path) return (spec, full); } + static void ResolveGitHubHousekeepingSpecPaths(GitHubHousekeepingSpec spec, string configFullPath) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var baseDir = Path.GetDirectoryName(configFullPath) ?? Directory.GetCurrentDirectory(); + if (spec.Runner is null) return; + + spec.Runner.RunnerTempPath = ResolvePathFromBaseNullable(baseDir, spec.Runner.RunnerTempPath); + spec.Runner.WorkRootPath = ResolvePathFromBaseNullable(baseDir, spec.Runner.WorkRootPath); + spec.Runner.RunnerRootPath = ResolvePathFromBaseNullable(baseDir, spec.Runner.RunnerRootPath); + spec.Runner.DiagnosticsRootPath = ResolvePathFromBaseNullable(baseDir, spec.Runner.DiagnosticsRootPath); + spec.Runner.ToolCachePath = ResolvePathFromBaseNullable(baseDir, spec.Runner.ToolCachePath); + } + static (string[] Names, string? Version, bool Prerelease, string[] Repositories)? ParseFindArgs(string[] argv) { var names = new List(); diff --git a/PowerForge.PowerShell/PowerForge.PowerShell.csproj b/PowerForge.PowerShell/PowerForge.PowerShell.csproj index 6ae5092b..4cb7b691 100644 --- a/PowerForge.PowerShell/PowerForge.PowerShell.csproj +++ b/PowerForge.PowerShell/PowerForge.PowerShell.csproj @@ -8,9 +8,17 @@ true CS1591 1.0.0 + PowerForge.PowerShell PowerForge PowerShell-hosted module pipeline services. PowerForge.PowerShell PowerForge + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + powershell;build;publishing;automation;module + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge.Tests/GitHubHousekeepingServiceTests.cs b/PowerForge.Tests/GitHubHousekeepingServiceTests.cs new file mode 100644 index 00000000..aed0d820 --- /dev/null +++ b/PowerForge.Tests/GitHubHousekeepingServiceTests.cs @@ -0,0 +1,236 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace PowerForge.Tests; + +public sealed class GitHubHousekeepingServiceTests +{ + [Fact] + public void Run_DryRun_AggregatesConfiguredSections() + { + var artifactHandler = new FakeGitHubArtifactsHandler(new[] + { + Artifact(id: 1, name: "test-results", daysAgo: 20), + Artifact(id: 2, name: "test-results", daysAgo: 10) + }); + var cacheHandler = new FakeGitHubCachesHandler(new[] + { + Cache(id: 11, key: "ubuntu-nuget", daysAgo: 20), + Cache(id: 12, key: "ubuntu-nuget", daysAgo: 5) + }); + + using var artifactClient = new HttpClient(artifactHandler); + using var cacheClient = new HttpClient(cacheHandler); + var service = new GitHubHousekeepingService( + new NullLogger(), + new GitHubArtifactCleanupService(new NullLogger(), artifactClient), + new GitHubActionsCacheCleanupService(new NullLogger(), cacheClient), + new RunnerHousekeepingService(new NullLogger())); + + var result = service.Run(new GitHubHousekeepingSpec + { + Repository = "EvotecIT/PSPublishModule", + Token = "test-token", + DryRun = true, + Runner = new GitHubHousekeepingRunnerSpec { Enabled = false }, + Artifacts = new GitHubHousekeepingArtifactSpec + { + Enabled = true, + KeepLatestPerName = 0, + MaxAgeDays = null + }, + Caches = new GitHubHousekeepingCacheSpec + { + Enabled = true, + KeepLatestPerKey = 0, + MaxAgeDays = null + } + }); + + Assert.True(result.Success); + Assert.Equal(new[] { "artifacts", "caches" }, result.RequestedSections); + Assert.Equal(new[] { "artifacts", "caches" }, result.CompletedSections); + Assert.Empty(result.FailedSections); + Assert.NotNull(result.Artifacts); + Assert.NotNull(result.Caches); + Assert.Equal(2, result.Artifacts!.PlannedDeletes); + Assert.Equal(2, result.Caches!.PlannedDeletes); + } + + [Fact] + public void Run_RemoteCleanupWithoutToken_FailsGracefully() + { + var service = new GitHubHousekeepingService(new NullLogger()); + + var result = service.Run(new GitHubHousekeepingSpec + { + Repository = "EvotecIT/OfficeIMO", + Token = string.Empty, + DryRun = true, + Artifacts = new GitHubHousekeepingArtifactSpec { Enabled = true }, + Caches = new GitHubHousekeepingCacheSpec { Enabled = false }, + Runner = new GitHubHousekeepingRunnerSpec { Enabled = false } + }); + + Assert.False(result.Success); + Assert.Contains("artifacts", result.FailedSections); + Assert.Contains("token", result.Message ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + private static FakeArtifact Artifact(long id, string name, int daysAgo) + { + var timestamp = DateTimeOffset.UtcNow.AddDays(-daysAgo); + return new FakeArtifact + { + Id = id, + Name = name, + SizeInBytes = 1024 + id, + CreatedAt = timestamp, + UpdatedAt = timestamp + }; + } + + private static FakeCache Cache(long id, string key, int daysAgo) + { + var timestamp = DateTimeOffset.UtcNow.AddDays(-daysAgo); + return new FakeCache + { + Id = id, + Key = key, + Ref = "refs/heads/main", + Version = "v1", + SizeInBytes = 1024 + id, + CreatedAt = timestamp, + LastAccessedAt = timestamp + }; + } + + private sealed class FakeGitHubArtifactsHandler : HttpMessageHandler + { + private readonly FakeArtifact[] _artifacts; + + public FakeGitHubArtifactsHandler(FakeArtifact[] artifacts) + { + _artifacts = artifacts ?? Array.Empty(); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Get && + request.RequestUri is not null && + request.RequestUri.AbsolutePath.Contains("/actions/artifacts", StringComparison.OrdinalIgnoreCase)) + { + var payload = new + { + total_count = _artifacts.Length, + artifacts = _artifacts.Select(a => new + { + id = a.Id, + name = a.Name, + size_in_bytes = a.SizeInBytes, + expired = false, + created_at = a.CreatedAt.ToString("O"), + updated_at = a.UpdatedAt.ToString("O"), + workflow_run = new { id = 1000 + a.Id } + }).ToArray() + }; + + var json = JsonSerializer.Serialize(payload); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Delete) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }); + } + } + + private sealed class FakeGitHubCachesHandler : HttpMessageHandler + { + private readonly FakeCache[] _caches; + + public FakeGitHubCachesHandler(FakeCache[] caches) + { + _caches = caches ?? Array.Empty(); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + + if (request.Method == HttpMethod.Get && path.Contains("/actions/cache/usage", StringComparison.OrdinalIgnoreCase)) + { + var usage = JsonSerializer.Serialize(new + { + active_caches_count = _caches.Length, + active_caches_size_in_bytes = _caches.Sum(c => c.SizeInBytes) + }); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(usage, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Get && path.Contains("/actions/caches", StringComparison.OrdinalIgnoreCase)) + { + var payload = JsonSerializer.Serialize(new + { + total_count = _caches.Length, + actions_caches = _caches.Select(c => new + { + id = c.Id, + key = c.Key, + @ref = c.Ref, + version = c.Version, + size_in_bytes = c.SizeInBytes, + created_at = c.CreatedAt.ToString("O"), + last_accessed_at = c.LastAccessedAt.ToString("O") + }).ToArray() + }); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Delete) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }); + } + } + + private sealed class FakeArtifact + { + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public long SizeInBytes { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + } + + private sealed class FakeCache + { + public long Id { get; set; } + public string Key { get; set; } = string.Empty; + public string Ref { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public long SizeInBytes { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastAccessedAt { get; set; } + } +} diff --git a/PowerForge.Tests/packages.lock.json b/PowerForge.Tests/packages.lock.json index 8c29dcf2..ad64b3a1 100644 --- a/PowerForge.Tests/packages.lock.json +++ b/PowerForge.Tests/packages.lock.json @@ -855,7 +855,7 @@ "YamlDotNet": "[15.1.2, )" } }, - "powerforge.web.cli": { + "PowerForge.Web.Build": { "type": "Project", "dependencies": { "PowerForge.Web": "[1.0.0, )" diff --git a/PowerForge.Web.Cli/PowerForge.Web.Cli.csproj b/PowerForge.Web.Cli/PowerForge.Web.Cli.csproj index aef1155b..b6ce151c 100644 --- a/PowerForge.Web.Cli/PowerForge.Web.Cli.csproj +++ b/PowerForge.Web.Cli/PowerForge.Web.Cli.csproj @@ -7,11 +7,19 @@ enable PowerForge.Web.Cli PowerForge.Web.Cli + PowerForge.Web.Build true powerforge-web PowerForge.Web command-line interface for building and serving static sites. 1.0.0 true + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + website;static-site;docs;automation;cli;tool + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge.Web/PowerForge.Web.csproj b/PowerForge.Web/PowerForge.Web.csproj index a08ea426..ad42cc2a 100644 --- a/PowerForge.Web/PowerForge.Web.csproj +++ b/PowerForge.Web/PowerForge.Web.csproj @@ -4,9 +4,19 @@ net8.0;net10.0 enable enable + 1.0.0 + PowerForge.Web PowerForge.Web + PowerForge.Web static-site engine and reusable website build components. true true + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + website;static-site;docs;automation;powerforge + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge/Models/DotNetRepositoryReleaseResult.cs b/PowerForge/Models/DotNetRepositoryReleaseResult.cs index 7a0dbd00..b6302ed9 100644 --- a/PowerForge/Models/DotNetRepositoryReleaseResult.cs +++ b/PowerForge/Models/DotNetRepositoryReleaseResult.cs @@ -43,6 +43,9 @@ public sealed class DotNetRepositoryProjectResult /// Resolved csproj path. public string CsprojPath { get; set; } = string.Empty; + /// Resolved NuGet package identifier. + public string PackageId { get; set; } = string.Empty; + /// Whether the project is considered packable. public bool IsPackable { get; set; } diff --git a/PowerForge/Models/GitHubActionsCacheCleanup.cs b/PowerForge/Models/GitHubActionsCacheCleanup.cs index 6afdcc6f..2ec7ed12 100644 --- a/PowerForge/Models/GitHubActionsCacheCleanup.cs +++ b/PowerForge/Models/GitHubActionsCacheCleanup.cs @@ -182,6 +182,11 @@ public sealed class GitHubActionsCacheCleanupResult /// public GitHubActionsCacheUsage? UsageBefore { get; set; } + /// + /// Cache usage reported by GitHub after cleanup finished. + /// + public GitHubActionsCacheUsage? UsageAfter { get; set; } + /// /// Total caches scanned from GitHub. /// diff --git a/PowerForge/Models/GitHubHousekeeping.cs b/PowerForge/Models/GitHubHousekeeping.cs new file mode 100644 index 00000000..15c9a5b0 --- /dev/null +++ b/PowerForge/Models/GitHubHousekeeping.cs @@ -0,0 +1,303 @@ +using System; + +namespace PowerForge; + +/// +/// Top-level configuration for GitHub housekeeping runs. +/// +public sealed class GitHubHousekeepingSpec +{ + /// + /// Optional GitHub API base URL (for example https://api.github.com/). + /// + public string? ApiBaseUrl { get; set; } + + /// + /// Repository in owner/repo format. + /// + public string? Repository { get; set; } + + /// + /// Optional GitHub token used for artifact/cache cleanup. + /// + public string? Token { get; set; } + + /// + /// Environment variable name used to resolve the GitHub token when is not provided. + /// + public string TokenEnvName { get; set; } = "GITHUB_TOKEN"; + + /// + /// When true, only plans deletions and does not execute them. + /// + public bool DryRun { get; set; } = true; + + /// + /// Artifact cleanup settings. + /// + public GitHubHousekeepingArtifactSpec Artifacts { get; set; } = new(); + + /// + /// Cache cleanup settings. + /// + public GitHubHousekeepingCacheSpec Caches { get; set; } = new(); + + /// + /// Runner cleanup settings. + /// + public GitHubHousekeepingRunnerSpec Runner { get; set; } = new(); +} + +/// +/// Artifact cleanup configuration for a housekeeping run. +/// +public sealed class GitHubHousekeepingArtifactSpec +{ + /// + /// Whether artifact cleanup is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Artifact name patterns to include. + /// + public string[] IncludeNames { get; set; } = Array.Empty(); + + /// + /// Artifact name patterns to exclude. + /// + public string[] ExcludeNames { get; set; } = Array.Empty(); + + /// + /// Number of newest artifacts kept per artifact name. + /// + public int KeepLatestPerName { get; set; } = 5; + + /// + /// Minimum age in days before deletion is allowed. + /// + public int? MaxAgeDays { get; set; } = 7; + + /// + /// Maximum number of artifacts to delete in one run. + /// + public int MaxDelete { get; set; } = 200; + + /// + /// Number of API records requested per page. + /// + public int PageSize { get; set; } = 100; + + /// + /// When true, the run fails if an artifact delete request fails. + /// + public bool FailOnDeleteError { get; set; } +} + +/// +/// Cache cleanup configuration for a housekeeping run. +/// +public sealed class GitHubHousekeepingCacheSpec +{ + /// + /// Whether cache cleanup is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Cache key patterns to include. + /// + public string[] IncludeKeys { get; set; } = Array.Empty(); + + /// + /// Cache key patterns to exclude. + /// + public string[] ExcludeKeys { get; set; } = Array.Empty(); + + /// + /// Number of newest caches kept per cache key. + /// + public int KeepLatestPerKey { get; set; } = 1; + + /// + /// Minimum age in days before deletion is allowed. + /// + public int? MaxAgeDays { get; set; } = 14; + + /// + /// Maximum number of caches to delete in one run. + /// + public int MaxDelete { get; set; } = 200; + + /// + /// Number of API records requested per page. + /// + public int PageSize { get; set; } = 100; + + /// + /// When true, the run fails if a cache delete request fails. + /// + public bool FailOnDeleteError { get; set; } +} + +/// +/// Runner cleanup configuration for a housekeeping run. +/// +public sealed class GitHubHousekeepingRunnerSpec +{ + /// + /// Whether runner cleanup is enabled. + /// + public bool Enabled { get; set; } + + /// + /// Optional explicit runner temp directory. + /// + public string? RunnerTempPath { get; set; } + + /// + /// Optional explicit work root. + /// + public string? WorkRootPath { get; set; } + + /// + /// Optional explicit runner root. + /// + public string? RunnerRootPath { get; set; } + + /// + /// Optional explicit diagnostics root. + /// + public string? DiagnosticsRootPath { get; set; } + + /// + /// Optional explicit tool cache path. + /// + public string? ToolCachePath { get; set; } + + /// + /// Minimum free disk required after cleanup, in GiB. + /// + public int? MinFreeGb { get; set; } = 20; + + /// + /// Threshold below which aggressive cleanup is enabled, in GiB. + /// + public int? AggressiveThresholdGb { get; set; } + + /// + /// Retention window for diagnostics files. + /// + public int DiagnosticsRetentionDays { get; set; } = 14; + + /// + /// Retention window for action working sets. + /// + public int ActionsRetentionDays { get; set; } = 7; + + /// + /// Retention window for tool cache directories. + /// + public int ToolCacheRetentionDays { get; set; } = 30; + + /// + /// Forces aggressive cleanup. + /// + public bool Aggressive { get; set; } + + /// + /// Enables diagnostics cleanup. + /// + public bool CleanDiagnostics { get; set; } = true; + + /// + /// Enables runner temp cleanup. + /// + public bool CleanRunnerTemp { get; set; } = true; + + /// + /// Enables action working set cleanup. + /// + public bool CleanActionsCache { get; set; } = true; + + /// + /// Enables runner tool cache cleanup. + /// + public bool CleanToolCache { get; set; } = true; + + /// + /// Enables dotnet nuget locals all --clear. + /// + public bool ClearDotNetCaches { get; set; } = true; + + /// + /// Enables Docker prune. + /// + public bool PruneDocker { get; set; } = true; + + /// + /// Includes Docker volumes during prune. + /// + public bool IncludeDockerVolumes { get; set; } = true; + + /// + /// Allows sudo on Unix for protected directories. + /// + public bool AllowSudo { get; set; } +} + +/// +/// Aggregate result for a GitHub housekeeping run. +/// +public sealed class GitHubHousekeepingResult +{ + /// + /// Repository used for remote GitHub cleanup. + /// + public string? Repository { get; set; } + + /// + /// Whether the run executed in dry-run mode. + /// + public bool DryRun { get; set; } + + /// + /// Whether the overall housekeeping run succeeded. + /// + public bool Success { get; set; } = true; + + /// + /// Optional warning or error message. + /// + public string? Message { get; set; } + + /// + /// Enabled sections in evaluation order. + /// + public string[] RequestedSections { get; set; } = Array.Empty(); + + /// + /// Sections that completed successfully. + /// + public string[] CompletedSections { get; set; } = Array.Empty(); + + /// + /// Sections that failed. + /// + public string[] FailedSections { get; set; } = Array.Empty(); + + /// + /// Artifact cleanup result when artifact cleanup is enabled. + /// + public GitHubArtifactCleanupResult? Artifacts { get; set; } + + /// + /// Cache cleanup result when cache cleanup is enabled. + /// + public GitHubActionsCacheCleanupResult? Caches { get; set; } + + /// + /// Runner cleanup result when runner cleanup is enabled. + /// + public RunnerHousekeepingResult? Runner { get; set; } +} diff --git a/PowerForge/PowerForge.csproj b/PowerForge/PowerForge.csproj index 323868c3..bede72e6 100644 --- a/PowerForge/PowerForge.csproj +++ b/PowerForge/PowerForge.csproj @@ -8,9 +8,17 @@ true CS1591 1.0.0 + PowerForge PowerForge core library: reusable engine for building, formatting, packaging and publishing PowerShell modules. PowerForge PowerForge + Przemyslaw Klys + Evotec Services sp. z o.o. + Copyright (c) 2024-2026 Evotec Services sp. z o.o. + powershell;build;publishing;automation;github + MIT + https://github.com/EvotecIT/PSPublishModule + git diff --git a/PowerForge/Services/DotNetRepositoryReleaseService.Execute.cs b/PowerForge/Services/DotNetRepositoryReleaseService.Execute.cs index ba752808..47367b22 100644 --- a/PowerForge/Services/DotNetRepositoryReleaseService.Execute.cs +++ b/PowerForge/Services/DotNetRepositoryReleaseService.Execute.cs @@ -120,13 +120,14 @@ public DotNetRepositoryReleaseResult Execute(DotNetRepositoryReleaseSpec spec) var dupPaths = string.Join("; ", group.Select(g => g.Path)); foreach (var item in group) { - projects.Add(new DotNetRepositoryProjectResult - { - ProjectName = item.Name, - CsprojPath = item.Path, - IsPackable = IsPackable(item.Path), - ErrorMessage = $"Duplicate project name found in multiple paths: {dupPaths}. Exclude directories or rename projects." - }); + projects.Add(new DotNetRepositoryProjectResult + { + ProjectName = item.Name, + CsprojPath = item.Path, + PackageId = ResolvePackageId(item.Path, item.Name), + IsPackable = IsPackable(item.Path), + ErrorMessage = $"Duplicate project name found in multiple paths: {dupPaths}. Exclude directories or rename projects." + }); } result.Success = false; _logger.Warn($"Duplicate project name '{group.Key}' found in multiple paths: {dupPaths}"); @@ -134,12 +135,13 @@ public DotNetRepositoryReleaseResult Execute(DotNetRepositoryReleaseSpec spec) } var entry = group.First(); - projects.Add(new DotNetRepositoryProjectResult - { - ProjectName = entry.Name, - CsprojPath = entry.Path, - IsPackable = IsPackable(entry.Path) - }); + projects.Add(new DotNetRepositoryProjectResult + { + ProjectName = entry.Name, + CsprojPath = entry.Path, + PackageId = ResolvePackageId(entry.Path, entry.Name), + IsPackable = IsPackable(entry.Path) + }); } if (projects.Count == 0) @@ -298,7 +300,7 @@ public DotNetRepositoryReleaseResult Execute(DotNetRepositoryReleaseSpec spec) continue; } - var filtered = FilterPackages(packResult.Packages, project.ProjectName, project.NewVersion!); + var filtered = FilterPackages(packResult.Packages, project.PackageId, project.NewVersion!); if (filtered.Count == 0) { project.ErrorMessage = $"No packages found for version {project.NewVersion}."; diff --git a/PowerForge/Services/DotNetRepositoryReleaseService.ExpectedAndZip.cs b/PowerForge/Services/DotNetRepositoryReleaseService.ExpectedAndZip.cs index f27be375..2f59ae42 100644 --- a/PowerForge/Services/DotNetRepositoryReleaseService.ExpectedAndZip.cs +++ b/PowerForge/Services/DotNetRepositoryReleaseService.ExpectedAndZip.cs @@ -27,11 +27,11 @@ private static Dictionary BuildExpectedVersionMap(Dictionary e.Name.LocalName.Equals("IsPackable", StringComparison.OrdinalIgnoreCase)) ?.Value; @@ -42,19 +42,39 @@ private static bool IsPackable(string csprojPath) catch { return true; - } - } - - private static string BuildReleaseZipPath(DotNetRepositoryProjectResult project, DotNetRepositoryReleaseSpec spec) - { - var csprojDir = Path.GetDirectoryName(project.CsprojPath) ?? string.Empty; - var cfg = string.IsNullOrWhiteSpace(spec.Configuration) ? "Release" : spec.Configuration.Trim(); - var releasePath = string.IsNullOrWhiteSpace(spec.ReleaseZipOutputPath) - ? Path.Combine(csprojDir, "bin", cfg) - : spec.ReleaseZipOutputPath!; - var version = string.IsNullOrWhiteSpace(project.NewVersion) ? "0.0.0" : project.NewVersion; - return Path.Combine(releasePath, $"{project.ProjectName}.{version}.zip"); - } + } + } + + private static string ResolvePackageId(string csprojPath, string fallbackProjectName) + { + try + { + var doc = XDocument.Load(csprojPath); + var packageId = doc.Descendants() + .FirstOrDefault(e => e.Name.LocalName.Equals("PackageId", StringComparison.OrdinalIgnoreCase)) + ?.Value; + + return string.IsNullOrWhiteSpace(packageId) + ? fallbackProjectName + : (packageId ?? string.Empty).Trim(); + } + catch + { + return fallbackProjectName; + } + } + + private static string BuildReleaseZipPath(DotNetRepositoryProjectResult project, DotNetRepositoryReleaseSpec spec) + { + var csprojDir = Path.GetDirectoryName(project.CsprojPath) ?? string.Empty; + var cfg = string.IsNullOrWhiteSpace(spec.Configuration) ? "Release" : spec.Configuration.Trim(); + var releasePath = string.IsNullOrWhiteSpace(spec.ReleaseZipOutputPath) + ? Path.Combine(csprojDir, "bin", cfg) + : spec.ReleaseZipOutputPath!; + var version = string.IsNullOrWhiteSpace(project.NewVersion) ? "0.0.0" : project.NewVersion; + var assetName = string.IsNullOrWhiteSpace(project.PackageId) ? project.ProjectName : project.PackageId; + return Path.Combine(releasePath, $"{assetName}.{version}.zip"); + } private static bool TryCreateReleaseZip( DotNetRepositoryProjectResult project, diff --git a/PowerForge/Services/DotNetRepositoryReleaseService.ValidationAndSort.cs b/PowerForge/Services/DotNetRepositoryReleaseService.ValidationAndSort.cs index c620e7e3..42da372c 100644 --- a/PowerForge/Services/DotNetRepositoryReleaseService.ValidationAndSort.cs +++ b/PowerForge/Services/DotNetRepositoryReleaseService.ValidationAndSort.cs @@ -66,7 +66,7 @@ private static IReadOnlyList BuildExcludeDirectories(IEnumerable } var latest = _resolver.ResolveLatest( - packageId: project.ProjectName, + packageId: string.IsNullOrWhiteSpace(project.PackageId) ? project.ProjectName : project.PackageId, sources: versionSources, credential: spec.VersionSourceCredential, includePrerelease: spec.IncludePrerelease); diff --git a/PowerForge/Services/DotNetRepositoryReleaseService.VersionAndPacking.cs b/PowerForge/Services/DotNetRepositoryReleaseService.VersionAndPacking.cs index 47cc11f9..a38aec4b 100644 --- a/PowerForge/Services/DotNetRepositoryReleaseService.VersionAndPacking.cs +++ b/PowerForge/Services/DotNetRepositoryReleaseService.VersionAndPacking.cs @@ -39,11 +39,11 @@ private string ResolveVersion( if (Version.TryParse(expectedVersion, out var exact)) return exact.ToString(); - var current = _resolver.ResolveLatest( - packageId: project.ProjectName, - sources: spec.VersionSources, - credential: spec.VersionSourceCredential, - includePrerelease: spec.IncludePrerelease); + var current = _resolver.ResolveLatest( + packageId: string.IsNullOrWhiteSpace(project.PackageId) ? project.ProjectName : project.PackageId, + sources: spec.VersionSources, + credential: spec.VersionSourceCredential, + includePrerelease: spec.IncludePrerelease); if (current is null) warning = $"No current package version found; using 0 baseline for '{expectedVersion}'."; @@ -97,7 +97,8 @@ private static DotNetPackResult PackProject(DotNetRepositoryProjectResult projec : Path.Combine(spec.RootPath, spec.OutputPath)); if (string.IsNullOrWhiteSpace(outputPath)) return null; - return Path.Combine(outputPath, $"{project.ProjectName}.{version}.nupkg"); + var packageId = string.IsNullOrWhiteSpace(project.PackageId) ? project.ProjectName : project.PackageId; + return Path.Combine(outputPath, $"{packageId}.{version}.nupkg"); } private static int RunDotnetPack(string csproj, string workingDirectory, string configuration, string? outputPath, out string stdErr, out string stdOut) diff --git a/PowerForge/Services/GitHubActionsCacheCleanupService.cs b/PowerForge/Services/GitHubActionsCacheCleanupService.cs index 518ed1ba..3c04cdad 100644 --- a/PowerForge/Services/GitHubActionsCacheCleanupService.cs +++ b/PowerForge/Services/GitHubActionsCacheCleanupService.cs @@ -142,6 +142,7 @@ public GitHubActionsCacheCleanupResult Prune(GitHubActionsCacheCleanupSpec spec) result.DeletedCaches = deleted.Count; result.DeletedBytes = deleted.Sum(c => c.SizeInBytes); result.FailedDeletes = failed.Count; + result.UsageAfter = TryGetUsage(normalized.ApiBaseUri, normalized.Repository, normalized.Token); result.Success = failed.Count == 0 || !normalized.FailOnDeleteError; if (!result.Success) diff --git a/PowerForge/Services/GitHubHousekeepingService.cs b/PowerForge/Services/GitHubHousekeepingService.cs new file mode 100644 index 00000000..9aa3ad5f --- /dev/null +++ b/PowerForge/Services/GitHubHousekeepingService.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PowerForge; + +/// +/// Orchestrates repository cache/artifact cleanup and optional runner housekeeping from one spec. +/// +public sealed class GitHubHousekeepingService +{ + private readonly ILogger _logger; + private readonly GitHubArtifactCleanupService _artifactService; + private readonly GitHubActionsCacheCleanupService _cacheService; + private readonly RunnerHousekeepingService _runnerService; + + /// + /// Creates a housekeeping orchestrator. + /// + public GitHubHousekeepingService( + ILogger logger, + GitHubArtifactCleanupService? artifactService = null, + GitHubActionsCacheCleanupService? cacheService = null, + RunnerHousekeepingService? runnerService = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _artifactService = artifactService ?? new GitHubArtifactCleanupService(logger); + _cacheService = cacheService ?? new GitHubActionsCacheCleanupService(logger); + _runnerService = runnerService ?? new RunnerHousekeepingService(logger); + } + + /// + /// Executes the configured housekeeping sections. + /// + public GitHubHousekeepingResult Run(GitHubHousekeepingSpec spec) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var requested = new List(); + var completed = new List(); + var failed = new List(); + static string? NormalizeNullable(string? value) + { + var text = value ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + return null; + + return text.Trim(); + } + + static string NormalizeRequired(string? value) + { + var text = value ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + return text.Trim(); + } + + if (spec.Artifacts.Enabled) requested.Add("artifacts"); + if (spec.Caches.Enabled) requested.Add("caches"); + if (spec.Runner.Enabled) requested.Add("runner"); + + var repositoryValue = spec.Repository; + var tokenValue = spec.Token; + + var result = new GitHubHousekeepingResult + { + Repository = NormalizeNullable(repositoryValue), + DryRun = spec.DryRun, + RequestedSections = requested.ToArray() + }; + + if (requested.Count == 0) + { + result.Message = "No housekeeping sections are enabled."; + return result; + } + + var requiresRemoteIdentity = spec.Artifacts.Enabled || spec.Caches.Enabled; + var repository = NormalizeRequired(repositoryValue); + var token = NormalizeRequired(tokenValue); + + if (requiresRemoteIdentity) + { + if (string.IsNullOrWhiteSpace(repository)) + { + failed.Add("artifacts"); + if (spec.Caches.Enabled) + failed.Add("caches"); + result.Success = false; + result.Message = "Repository is required for GitHub artifact/cache cleanup."; + } + + if (string.IsNullOrWhiteSpace(token)) + { + if (spec.Artifacts.Enabled && !failed.Contains("artifacts", StringComparer.OrdinalIgnoreCase)) + failed.Add("artifacts"); + if (spec.Caches.Enabled && !failed.Contains("caches", StringComparer.OrdinalIgnoreCase)) + failed.Add("caches"); + result.Success = false; + result.Message = string.IsNullOrWhiteSpace(result.Message) + ? "GitHub token is required for GitHub artifact/cache cleanup." + : result.Message + " GitHub token is required for GitHub artifact/cache cleanup."; + } + } + + if (spec.Artifacts.Enabled && !failed.Contains("artifacts", StringComparer.OrdinalIgnoreCase)) + { + var artifactsResult = _artifactService.Prune(new GitHubArtifactCleanupSpec + { + ApiBaseUrl = spec.ApiBaseUrl, + Repository = repository, + Token = token, + IncludeNames = spec.Artifacts.IncludeNames ?? Array.Empty(), + ExcludeNames = spec.Artifacts.ExcludeNames ?? Array.Empty(), + KeepLatestPerName = spec.Artifacts.KeepLatestPerName, + MaxAgeDays = spec.Artifacts.MaxAgeDays, + MaxDelete = spec.Artifacts.MaxDelete, + PageSize = spec.Artifacts.PageSize, + DryRun = spec.DryRun, + FailOnDeleteError = spec.Artifacts.FailOnDeleteError + }); + + result.Artifacts = artifactsResult; + if (artifactsResult.Success) completed.Add("artifacts"); else failed.Add("artifacts"); + } + + if (spec.Caches.Enabled && !failed.Contains("caches", StringComparer.OrdinalIgnoreCase)) + { + var cachesResult = _cacheService.Prune(new GitHubActionsCacheCleanupSpec + { + ApiBaseUrl = spec.ApiBaseUrl, + Repository = repository, + Token = token, + IncludeKeys = spec.Caches.IncludeKeys ?? Array.Empty(), + ExcludeKeys = spec.Caches.ExcludeKeys ?? Array.Empty(), + KeepLatestPerKey = spec.Caches.KeepLatestPerKey, + MaxAgeDays = spec.Caches.MaxAgeDays, + MaxDelete = spec.Caches.MaxDelete, + PageSize = spec.Caches.PageSize, + DryRun = spec.DryRun, + FailOnDeleteError = spec.Caches.FailOnDeleteError + }); + + result.Caches = cachesResult; + if (cachesResult.Success) completed.Add("caches"); else failed.Add("caches"); + } + + if (spec.Runner.Enabled) + { + var runnerResult = _runnerService.Clean(new RunnerHousekeepingSpec + { + RunnerTempPath = spec.Runner.RunnerTempPath, + WorkRootPath = spec.Runner.WorkRootPath, + RunnerRootPath = spec.Runner.RunnerRootPath, + DiagnosticsRootPath = spec.Runner.DiagnosticsRootPath, + ToolCachePath = spec.Runner.ToolCachePath, + MinFreeGb = spec.Runner.MinFreeGb, + AggressiveThresholdGb = spec.Runner.AggressiveThresholdGb, + DiagnosticsRetentionDays = spec.Runner.DiagnosticsRetentionDays, + ActionsRetentionDays = spec.Runner.ActionsRetentionDays, + ToolCacheRetentionDays = spec.Runner.ToolCacheRetentionDays, + DryRun = spec.DryRun, + Aggressive = spec.Runner.Aggressive, + CleanDiagnostics = spec.Runner.CleanDiagnostics, + CleanRunnerTemp = spec.Runner.CleanRunnerTemp, + CleanActionsCache = spec.Runner.CleanActionsCache, + CleanToolCache = spec.Runner.CleanToolCache, + ClearDotNetCaches = spec.Runner.ClearDotNetCaches, + PruneDocker = spec.Runner.PruneDocker, + IncludeDockerVolumes = spec.Runner.IncludeDockerVolumes, + AllowSudo = spec.Runner.AllowSudo + }); + + result.Runner = runnerResult; + if (runnerResult.Success) completed.Add("runner"); else failed.Add("runner"); + } + + result.CompletedSections = completed.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + result.FailedSections = failed.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + result.Success = result.FailedSections.Length == 0; + + if (!result.Success && string.IsNullOrWhiteSpace(result.Message)) + result.Message = $"Housekeeping failed for section(s): {string.Join(", ", result.FailedSections)}."; + + _logger.Info($"GitHub housekeeping requested: {string.Join(", ", result.RequestedSections)}."); + _logger.Info($"GitHub housekeeping completed: {string.Join(", ", result.CompletedSections)}."); + if (result.FailedSections.Length > 0) + _logger.Warn($"GitHub housekeeping failed: {string.Join(", ", result.FailedSections)}."); + + return result; + } +} diff --git a/README.MD b/README.MD index 2443114c..66866583 100644 --- a/README.MD +++ b/README.MD @@ -143,7 +143,7 @@ powerforge github artifacts prune --name "test-results*,coverage*,github-pages" powerforge github artifacts prune --apply --keep 5 --max-age-days 7 --max-delete 200 ``` -GitHub cache cleanup and runner housekeeping: +GitHub cache cleanup, runner housekeeping, and config-driven orchestration: ```powershell # GitHub Actions cache cleanup (dry-run by default) @@ -151,9 +151,48 @@ powerforge github caches prune --key "ubuntu-*,windows-*" --keep 1 --max-age-day # Runner cleanup for hosted/self-hosted GitHub Actions runners powerforge github runner cleanup --apply --min-free-gb 20 + +# Config-driven housekeeping (auto-loads .powerforge/github-housekeeping.json when present) +powerforge github housekeeping --apply ``` -If you want the shortest workflow possible across repos, use the reusable composite action: +The recommended cross-repo setup is one config file plus one reusable workflow: + +```json +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/github.housekeeping.schema.json", + "repository": "EvotecIT/OfficeIMO", + "tokenEnvName": "GITHUB_TOKEN", + "artifacts": { + "enabled": true, + "keepLatestPerName": 10, + "maxAgeDays": 7 + }, + "caches": { + "enabled": true, + "keepLatestPerKey": 2, + "maxAgeDays": 14 + }, + "runner": { + "enabled": false + } +} +``` + +```yaml +permissions: + contents: read + actions: write + +jobs: + housekeeping: + uses: EvotecIT/PSPublishModule/.github/workflows/reusable-github-housekeeping.yml@main + with: + config-path: ./.powerforge/github-housekeeping.json + secrets: inherit +``` + +If you need direct action usage instead of the reusable workflow: ```yaml permissions: @@ -164,11 +203,21 @@ jobs: housekeeping: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 - uses: EvotecIT/PSPublishModule/.github/actions/github-housekeeping@main with: + config-path: ./.powerforge/github-housekeeping.json github-token: ${{ secrets.GITHUB_TOKEN }} ``` +For release/build packaging, this repo now also ships a standard project-build entrypoint: + +```powershell +.\Build\Build-Project.ps1 +.\Build\Build-Project.ps1 -Plan +.\Build\Build-Project.ps1 -PublishNuget $true -PublishGitHub $true +``` + Introduced in **1.0.0** a new way to build PowerShell module based on DSL language. ```powershell diff --git a/Schemas/github.housekeeping.schema.json b/Schemas/github.housekeeping.schema.json new file mode 100644 index 00000000..a3ba0ff5 --- /dev/null +++ b/Schemas/github.housekeeping.schema.json @@ -0,0 +1,150 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GitHub Housekeeping Configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "ApiBaseUrl": { + "type": [ + "string", + "null" + ] + }, + "Repository": { + "type": [ + "string", + "null" + ] + }, + "Token": { + "type": [ + "string", + "null" + ] + }, + "TokenEnvName": { + "type": "string" + }, + "DryRun": { + "type": "boolean" + }, + "Artifacts": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { "type": "boolean" }, + "IncludeNames": { + "type": "array", + "items": { "type": "string" } + }, + "ExcludeNames": { + "type": "array", + "items": { "type": "string" } + }, + "KeepLatestPerName": { "type": "integer", "minimum": 0 }, + "MaxAgeDays": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "MaxDelete": { "type": "integer", "minimum": 1 }, + "PageSize": { "type": "integer", "minimum": 1, "maximum": 100 }, + "FailOnDeleteError": { "type": "boolean" } + } + }, + "Caches": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { "type": "boolean" }, + "IncludeKeys": { + "type": "array", + "items": { "type": "string" } + }, + "ExcludeKeys": { + "type": "array", + "items": { "type": "string" } + }, + "KeepLatestPerKey": { "type": "integer", "minimum": 0 }, + "MaxAgeDays": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "MaxDelete": { "type": "integer", "minimum": 1 }, + "PageSize": { "type": "integer", "minimum": 1, "maximum": 100 }, + "FailOnDeleteError": { "type": "boolean" } + } + }, + "Runner": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { "type": "boolean" }, + "RunnerTempPath": { + "type": [ + "string", + "null" + ] + }, + "WorkRootPath": { + "type": [ + "string", + "null" + ] + }, + "RunnerRootPath": { + "type": [ + "string", + "null" + ] + }, + "DiagnosticsRootPath": { + "type": [ + "string", + "null" + ] + }, + "ToolCachePath": { + "type": [ + "string", + "null" + ] + }, + "MinFreeGb": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "AggressiveThresholdGb": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "DiagnosticsRetentionDays": { "type": "integer", "minimum": 0 }, + "ActionsRetentionDays": { "type": "integer", "minimum": 0 }, + "ToolCacheRetentionDays": { "type": "integer", "minimum": 0 }, + "Aggressive": { "type": "boolean" }, + "CleanDiagnostics": { "type": "boolean" }, + "CleanRunnerTemp": { "type": "boolean" }, + "CleanActionsCache": { "type": "boolean" }, + "CleanToolCache": { "type": "boolean" }, + "ClearDotNetCaches": { "type": "boolean" }, + "PruneDocker": { "type": "boolean" }, + "IncludeDockerVolumes": { "type": "boolean" }, + "AllowSudo": { "type": "boolean" } + } + } + } +} From ae228ebed7a40147f6b2c34a2aacae938842bcbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 12 Mar 2026 18:29:50 +0100 Subject: [PATCH 5/7] Add multi-runtime tool release builds --- Build/Build-PowerForge.ps1 | 469 +++++++++++++++++++++++++--------- Build/Build-PowerForgeWeb.ps1 | 60 +++++ README.MD | 13 + 3 files changed, 428 insertions(+), 114 deletions(-) create mode 100644 Build/Build-PowerForgeWeb.ps1 diff --git a/Build/Build-PowerForge.ps1 b/Build/Build-PowerForge.ps1 index 32d2fef4..f44b096f 100644 --- a/Build/Build-PowerForge.ps1 +++ b/Build/Build-PowerForge.ps1 @@ -1,4 +1,6 @@ [CmdletBinding()] param( + [ValidateSet('PowerForge', 'PowerForgeWeb', 'All')] + [string[]] $Tool = @('PowerForge'), [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'osx-x64', 'osx-arm64')] [string[]] $Runtime = @('win-x64'), [ValidateSet('Debug', 'Release')] @@ -12,163 +14,402 @@ [switch] $Zip, [switch] $UseStaging = $true, [switch] $KeepSymbols, - [switch] $KeepDocs + [switch] $KeepDocs, + [switch] $PublishGitHub, + [string] $GitHubUsername = 'EvotecIT', + [string] $GitHubRepositoryName = 'PSPublishModule', + [string] $GitHubAccessToken, + [string] $GitHubAccessTokenFilePath, + [string] $GitHubAccessTokenEnvName = 'GITHUB_TOKEN', + [string] $GitHubTagName, + [string] $GitHubReleaseName, + [switch] $GenerateReleaseNotes = $true, + [switch] $IsPreRelease ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -function Write-Header($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan } -function Write-Ok($t) { Write-Host ("{0} {1}" -f ([char]0x2705), $t) -ForegroundColor Green } # ✅ -function Write-Step($t) { Write-Host ("{0} {1}" -f ([char]0x1F6E0), $t) -ForegroundColor Yellow } # 🛠 (no VS16) +function Write-Header($Text) { Write-Host "`n=== $Text ===" -ForegroundColor Cyan } +function Write-Step($Text) { Write-Host "-> $Text" -ForegroundColor Yellow } +function Write-Ok($Text) { Write-Host "[ok] $Text" -ForegroundColor Green } $repoRoot = (Resolve-Path -LiteralPath ([IO.Path]::GetFullPath([IO.Path]::Combine($PSScriptRoot, '..')))).Path -$proj = Join-Path $repoRoot 'PowerForge.Cli/PowerForge.Cli.csproj' +$moduleManifest = Join-Path $repoRoot 'PSPublishModule\PSPublishModule.psd1' +$outDirProvided = $PSBoundParameters.ContainsKey('OutDir') -and -not [string]::IsNullOrWhiteSpace($OutDir) -if (-not (Test-Path -LiteralPath $proj)) { throw "Project not found: $proj" } +if ($PublishGitHub) { + $Zip = $true +} -# When using a staging publish dir, treat the output as an exact snapshot and clear it by default. -if ($UseStaging -and -not $PSBoundParameters.ContainsKey('ClearOut')) { - $ClearOut = $true +$toolDefinitions = @{ + PowerForge = @{ + ProjectPath = Join-Path $repoRoot 'PowerForge.Cli\PowerForge.Cli.csproj' + ArtifactRoot = Join-Path $repoRoot 'Artifacts\PowerForge' + OutputName = 'PowerForge' + OutputNameLower = 'powerforge' + PublishedBinaryCandidates = @('PowerForge.Cli.exe', 'PowerForge.Cli') + } + PowerForgeWeb = @{ + ProjectPath = Join-Path $repoRoot 'PowerForge.Web.Cli\PowerForge.Web.Cli.csproj' + ArtifactRoot = Join-Path $repoRoot 'Artifacts\PowerForgeWeb' + OutputName = 'PowerForgeWeb' + OutputNameLower = 'powerforge-web' + PublishedBinaryCandidates = @('PowerForge.Web.Cli.exe', 'PowerForge.Web.Cli') + } } -$outDirProvided = $PSBoundParameters.ContainsKey('OutDir') -$rids = @( - @($Runtime) | - Where-Object { $_ -and $_.Trim() } | - ForEach-Object { $_.Trim() } | - Select-Object -Unique -) -if ($rids.Count -eq 0) { throw "Runtime must not be empty." } -$multiRuntime = $rids.Count -gt 1 +function Resolve-ToolSelection { + param([string[]] $SelectedTools) + + $normalized = @( + @($SelectedTools) | + Where-Object { $_ -and $_.Trim() } | + ForEach-Object { $_.Trim() } | + Select-Object -Unique + ) + + if ($normalized.Count -eq 0) { + throw 'Tool selection cannot be empty.' + } + + if ($normalized -contains 'All') { + return @('PowerForge', 'PowerForgeWeb') + } + + return $normalized +} + +function Resolve-ProjectVersion { + param([Parameter(Mandatory)][string] $ProjectPath) + + [xml] $xml = Get-Content -LiteralPath $ProjectPath -Raw + $node = $xml.SelectSingleNode("/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='Version']") + if (-not $node) { + $node = $xml.SelectSingleNode("/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='VersionPrefix']") + } + + if (-not $node -or [string]::IsNullOrWhiteSpace($node.InnerText)) { + throw "Unable to resolve Version/VersionPrefix from $ProjectPath" + } + + return $node.InnerText.Trim() +} function Resolve-OutDir { - param([Parameter(Mandatory)][string] $Rid) + param( + [Parameter(Mandatory)][string] $ToolName, + [Parameter(Mandatory)][string] $Rid, + [Parameter(Mandatory)][string] $DefaultRoot, + [Parameter(Mandatory)][bool] $MultiTool, + [Parameter(Mandatory)][bool] $MultiRuntime + ) + if ($outDirProvided) { - if ($multiRuntime) { - return Join-Path $OutDir ("{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) + $root = $OutDir + if ($MultiTool) { + $root = Join-Path $root $ToolName } + if ($MultiRuntime) { + return Join-Path $root ("{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) + } + return $root + } + + return Join-Path $DefaultRoot ("{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) +} + +function Resolve-ToolOutputRoot { + param( + [Parameter(Mandatory)][string] $ToolName, + [Parameter(Mandatory)][string] $DefaultRoot, + [Parameter(Mandatory)][bool] $MultiTool + ) + + if ($outDirProvided) { + if ($MultiTool) { + return Join-Path $OutDir $ToolName + } + return $OutDir } - return Join-Path $repoRoot ("Artifacts/PowerForge/{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) + + return $DefaultRoot +} + +function Remove-DirectoryContents { + param([Parameter(Mandatory)][string] $Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return + } + + Get-ChildItem -Path $Path -Force -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue +} + +function Resolve-GitHubToken { + if (-not $PublishGitHub) { + return $null + } + + if (-not [string]::IsNullOrWhiteSpace($GitHubAccessToken)) { + return $GitHubAccessToken.Trim() + } + + if (-not [string]::IsNullOrWhiteSpace($GitHubAccessTokenFilePath)) { + $tokenPath = if ([IO.Path]::IsPathRooted($GitHubAccessTokenFilePath)) { + $GitHubAccessTokenFilePath + } else { + Join-Path $repoRoot $GitHubAccessTokenFilePath + } + + if (Test-Path -LiteralPath $tokenPath) { + return (Get-Content -LiteralPath $tokenPath -Raw).Trim() + } + } + + if (-not [string]::IsNullOrWhiteSpace($GitHubAccessTokenEnvName)) { + $envToken = [Environment]::GetEnvironmentVariable($GitHubAccessTokenEnvName) + if (-not [string]::IsNullOrWhiteSpace($envToken)) { + return $envToken.Trim() + } + } + + throw 'GitHub token is required when -PublishGitHub is used.' } +function Publish-GitHubAssets { + param( + [Parameter(Mandatory)][string] $ToolName, + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string[]] $AssetPaths, + [Parameter(Mandatory)][string] $Token + ) + + if ($AssetPaths.Count -eq 0) { + throw "No assets were created for $ToolName, so there is nothing to publish." + } + + Import-Module $moduleManifest -Force -ErrorAction Stop + + $tagName = if (-not [string]::IsNullOrWhiteSpace($GitHubTagName) -and $selectedTools.Count -eq 1) { + $GitHubTagName.Trim() + } else { + "$ToolName-v$Version" + } + + $releaseName = if (-not [string]::IsNullOrWhiteSpace($GitHubReleaseName) -and $selectedTools.Count -eq 1) { + $GitHubReleaseName.Trim() + } else { + "$ToolName $Version" + } + + Write-Step "Publishing $ToolName assets to GitHub release $tagName" + $publishResult = Send-GitHubRelease ` + -GitHubUsername $GitHubUsername ` + -GitHubRepositoryName $GitHubRepositoryName ` + -GitHubAccessToken $Token ` + -TagName $tagName ` + -ReleaseName $releaseName ` + -GenerateReleaseNotes:$GenerateReleaseNotes ` + -IsPreRelease:$IsPreRelease ` + -AssetFilePaths $AssetPaths + + if (-not $publishResult.Succeeded) { + throw "GitHub release publish failed for ${ToolName}: $($publishResult.ErrorMessage)" + } + + Write-Ok "$ToolName release published -> $($publishResult.ReleaseUrl)" +} + +$selectedTools = @(Resolve-ToolSelection -SelectedTools $Tool) +$multiTool = $selectedTools.Count -gt 1 +$rids = @( + @($Runtime) | + Where-Object { $_ -and $_.Trim() } | + ForEach-Object { $_.Trim() } | + Select-Object -Unique +) +if ($rids.Count -eq 0) { + throw 'Runtime must not be empty.' +} +$multiRuntime = $rids.Count -gt 1 $singleFile = $Flavor -in @('SingleContained', 'SingleFx') $selfContained = $Flavor -in @('SingleContained', 'Portable') $compress = $singleFile $selfExtract = $Flavor -eq 'SingleContained' +$gitHubToken = Resolve-GitHubToken +$publishedAssets = @{} -Write-Header "Build PowerForge ($Flavor)" +Write-Header "Build tools ($Flavor)" Write-Step "Framework -> $Framework" Write-Step "Configuration -> $Configuration" +Write-Step ("Tools -> {0}" -f ($selectedTools -join ', ')) Write-Step ("Runtimes -> {0}" -f ($rids -join ', ')) -foreach ($rid in $rids) { - $outDirThis = Resolve-OutDir -Rid $rid - New-Item -ItemType Directory -Force -Path $outDirThis | Out-Null - - $publishDir = $outDirThis - $stagingDir = $null - if ($UseStaging) { - $stagingDir = Join-Path $env:TEMP ("PowerForge.Cli.publish." + [guid]::NewGuid().ToString("N")) - $publishDir = $stagingDir - Write-Step "Using staging publish dir -> $publishDir" - if (Test-Path $publishDir) { - Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue - } - New-Item -ItemType Directory -Force -Path $publishDir | Out-Null - } - - Write-Step "Runtime -> $rid" - Write-Step "Publishing -> $publishDir" - - $publishArgs = @( - 'publish', $proj, - '-c', $Configuration, - '-f', $Framework, - '-r', $rid, - "--self-contained:$selfContained", - "/p:PublishSingleFile=$singleFile", - "/p:PublishReadyToRun=false", - "/p:PublishTrimmed=false", - "/p:IncludeAllContentForSelfExtract=$singleFile", - "/p:IncludeNativeLibrariesForSelfExtract=$selfExtract", - "/p:EnableCompressionInSingleFile=$compress", - "/p:DebugType=None", - "/p:DebugSymbols=false", - "/p:GenerateDocumentationFile=false", - "/p:CopyDocumentationFiles=false", - "/p:ExcludeSymbolsFromSingleFile=true", - "/p:ErrorOnDuplicatePublishOutputFiles=false", - "/p:UseAppHost=true", - "/p:PublishDir=$publishDir" - ) +foreach ($toolName in $selectedTools) { + $definition = $toolDefinitions[$toolName] + if (-not $definition) { + throw "Unsupported tool: $toolName" + } - if ($ClearOut -and (Test-Path $outDirThis) -and ($publishDir -eq $outDirThis)) { - Write-Step "Clearing $outDirThis" - Get-ChildItem -Path $outDirThis -Recurse -Force -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + $projectPath = [string] $definition.ProjectPath + if (-not (Test-Path -LiteralPath $projectPath)) { + throw "Project not found: $projectPath" } - dotnet @publishArgs - if ($LASTEXITCODE -ne 0) { throw "Publish failed ($LASTEXITCODE)" } + $artifactRoot = [string] $definition.ArtifactRoot + $toolOutputRoot = Resolve-ToolOutputRoot -ToolName $toolName -DefaultRoot $artifactRoot -MultiTool:$multiTool + $outputName = [string] $definition.OutputName + $lowerAlias = [string] $definition.OutputNameLower + $candidateNames = @($definition.PublishedBinaryCandidates) + $version = Resolve-ProjectVersion -ProjectPath $projectPath + $publishedAssets[$toolName] = @() - if (-not $KeepSymbols) { - Write-Step "Removing symbols (*.pdb)" - Get-ChildItem -Path $publishDir -Filter *.pdb -File -Recurse -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - } + foreach ($rid in $rids) { + $outDirThis = Resolve-OutDir -ToolName $toolName -Rid $rid -DefaultRoot $artifactRoot -MultiTool:$multiTool -MultiRuntime:$multiRuntime + New-Item -ItemType Directory -Force -Path $outDirThis | Out-Null - if (-not $KeepDocs) { - Write-Step "Removing docs (*.xml, *.pdf)" - Get-ChildItem -Path $publishDir -File -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.Extension -in @('.xml', '.pdf') } | - Remove-Item -Force -ErrorAction SilentlyContinue - } + $publishDir = $outDirThis + $stagingDir = $null + if ($UseStaging) { + $stagingDir = Join-Path $env:TEMP ($outputName + '.publish.' + [guid]::NewGuid().ToString('N')) + $publishDir = $stagingDir + Write-Step "Using staging publish dir -> $publishDir" + if (Test-Path -LiteralPath $publishDir) { + Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue + } + New-Item -ItemType Directory -Force -Path $publishDir | Out-Null + } + + Write-Step "$toolName runtime -> $rid" + Write-Step "Publishing -> $publishDir" - $ridIsWindows = $rid -like 'win-*' - $cliExe = Join-Path $publishDir 'PowerForge.Cli.exe' - $cliExeAlt = Join-Path $publishDir 'PowerForge.Cli' - $friendlyExe = Join-Path $publishDir ($ridIsWindows ? 'powerforge.exe' : 'powerforge') - foreach ($candidate in @($cliExe, $cliExeAlt)) { - if (-not (Test-Path -LiteralPath $candidate)) { continue } + $publishArgs = @( + 'publish', $projectPath, + '-c', $Configuration, + '-f', $Framework, + '-r', $rid, + "--self-contained:$selfContained", + "/p:PublishSingleFile=$singleFile", + "/p:PublishReadyToRun=false", + "/p:PublishTrimmed=false", + "/p:IncludeAllContentForSelfExtract=$selfExtract", + "/p:IncludeNativeLibrariesForSelfExtract=$selfExtract", + "/p:EnableCompressionInSingleFile=$compress", + "/p:EnableSingleFileAnalyzer=false", + "/p:DebugType=None", + "/p:DebugSymbols=false", + "/p:GenerateDocumentationFile=false", + "/p:CopyDocumentationFiles=false", + "/p:ExcludeSymbolsFromSingleFile=true", + "/p:ErrorOnDuplicatePublishOutputFiles=false", + "/p:UseAppHost=true", + "/p:PublishDir=$publishDir" + ) - # Keep a stable, user-friendly binary name without duplicating 50+ MB on disk. - if (Test-Path -LiteralPath $friendlyExe) { - Remove-Item -LiteralPath $friendlyExe -Force -ErrorAction SilentlyContinue + if ($ClearOut -and (Test-Path -LiteralPath $outDirThis) -and ($publishDir -eq $outDirThis)) { + Write-Step "Clearing $outDirThis" + Remove-DirectoryContents -Path $outDirThis } - Move-Item -LiteralPath $candidate -Destination $friendlyExe -Force - break - } - if ($ClearOut -and (Test-Path $outDirThis) -and ($publishDir -ne $outDirThis)) { - Write-Step "Clearing $outDirThis" - Get-ChildItem -Path $outDirThis -Recurse -Force -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue - } + dotnet @publishArgs + if ($LASTEXITCODE -ne 0) { + throw "Publish failed for $toolName ($LASTEXITCODE)" + } - if ($publishDir -ne $outDirThis) { - Write-Step "Copying publish output -> $outDirThis" - Copy-Item -Path (Join-Path $publishDir '*') -Destination $outDirThis -Recurse -Force + if (-not $KeepSymbols) { + Write-Step "Removing symbols (*.pdb)" + Get-ChildItem -Path $publishDir -Filter *.pdb -File -Recurse -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + } + + if (-not $KeepDocs) { + Write-Step "Removing docs (*.xml, *.pdf)" + Get-ChildItem -Path $publishDir -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in @('.xml', '.pdf') } | + Remove-Item -Force -ErrorAction SilentlyContinue + } + + $ridIsWindows = $rid -like 'win-*' + $friendlyBinaryName = if ($ridIsWindows) { $outputName + '.exe' } else { $outputName } + $friendlyBinary = Join-Path $publishDir $friendlyBinaryName + foreach ($candidateName in $candidateNames) { + $candidatePath = Join-Path $publishDir $candidateName + if (-not (Test-Path -LiteralPath $candidatePath)) { + continue + } + + if (Test-Path -LiteralPath $friendlyBinary) { + Remove-Item -LiteralPath $friendlyBinary -Force -ErrorAction SilentlyContinue + } + + Move-Item -LiteralPath $candidatePath -Destination $friendlyBinary -Force + break + } + + if (-not (Test-Path -LiteralPath $friendlyBinary)) { + throw "Friendly output binary was not created for $toolName ($rid): $friendlyBinary" + } + + if ($ClearOut -and (Test-Path -LiteralPath $outDirThis) -and ($publishDir -ne $outDirThis)) { + Write-Step "Clearing $outDirThis" + Remove-DirectoryContents -Path $outDirThis + } + + if ($publishDir -ne $outDirThis) { + Write-Step "Copying publish output -> $outDirThis" + Copy-Item -Path (Join-Path $publishDir '*') -Destination $outDirThis -Recurse -Force + } + + if ($Zip) { + $zipName = "{0}-{1}-{2}-{3}-{4}.zip" -f $outputName, $version, $Framework, $rid, $Flavor + $zipPath = Join-Path (Split-Path -Parent $outDirThis) $zipName + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + Write-Step "Create zip -> $zipPath" + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::CreateFromDirectory($outDirThis, $zipPath) + $publishedAssets[$toolName] += $zipPath + } + + if ($rid -notlike 'win-*') { + $lowerAliasPath = Join-Path $outDirThis $lowerAlias + $friendlyPublishedBinary = Join-Path $outDirThis $outputName + if ((Test-Path -LiteralPath $friendlyPublishedBinary) -and -not (Test-Path -LiteralPath $lowerAliasPath)) { + Copy-Item -LiteralPath $friendlyPublishedBinary -Destination $lowerAliasPath -Force + } + } + + if ($stagingDir -and (Test-Path -LiteralPath $stagingDir)) { + Remove-Item -Path $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } } - if ($Zip) { - $zipName = "PowerForge-" + $Framework + "-" + $rid + "-" + $Flavor + "-" + (Get-Date -Format 'yyyyMMdd-HHmm') + ".zip" - $zipPath = Join-Path (Split-Path -Parent $outDirThis) $zipName - if (Test-Path $zipPath) { Remove-Item -Force $zipPath } - Write-Step ("Create zip -> {0}" -f $zipPath) - Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::CreateFromDirectory($outDirThis, $zipPath) + if (-not (Test-Path -LiteralPath $toolOutputRoot)) { + New-Item -ItemType Directory -Force -Path $toolOutputRoot | Out-Null } - if ($stagingDir -and (Test-Path $stagingDir)) { - Remove-Item -Path $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + $manifestPath = Join-Path $toolOutputRoot 'release-manifest.json' + $manifest = [ordered]@{ + Tool = $toolName + Version = $version + Framework = $Framework + Flavor = $Flavor + Runtimes = $rids + Assets = @($publishedAssets[$toolName]) + } | ConvertTo-Json -Depth 5 + Set-Content -LiteralPath $manifestPath -Value $manifest + Write-Ok "$toolName artifacts -> $toolOutputRoot" + + if ($PublishGitHub) { + Publish-GitHubAssets -ToolName $toolName -Version $version -AssetPaths @($publishedAssets[$toolName]) -Token $gitHubToken } } -if ($multiRuntime) { - $root = if ($outDirProvided) { $OutDir } else { Join-Path $repoRoot 'Artifacts/PowerForge' } - Write-Ok ("Built PowerForge -> {0}" -f $root) -} else { - Write-Ok ("Built PowerForge -> {0}" -f (Resolve-OutDir -Rid $rids[0])) +if ($multiTool) { + $root = if ($outDirProvided) { $OutDir } else { Join-Path $repoRoot 'Artifacts' } + Write-Ok "Built tool artifacts -> $root" } diff --git a/Build/Build-PowerForgeWeb.ps1 b/Build/Build-PowerForgeWeb.ps1 new file mode 100644 index 00000000..1923b221 --- /dev/null +++ b/Build/Build-PowerForgeWeb.ps1 @@ -0,0 +1,60 @@ +[CmdletBinding()] param( + [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'osx-x64', 'osx-arm64')] + [string[]] $Runtime = @('win-x64'), + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Release', + [ValidateSet('net10.0', 'net8.0')] + [string] $Framework = 'net10.0', + [ValidateSet('SingleContained', 'SingleFx', 'Portable', 'Fx')] + [string] $Flavor = 'SingleContained', + [string] $OutDir, + [switch] $ClearOut, + [switch] $Zip, + [switch] $UseStaging = $true, + [switch] $KeepSymbols, + [switch] $KeepDocs, + [switch] $PublishGitHub, + [string] $GitHubUsername = 'EvotecIT', + [string] $GitHubRepositoryName = 'PSPublishModule', + [string] $GitHubAccessToken, + [string] $GitHubAccessTokenFilePath, + [string] $GitHubAccessTokenEnvName = 'GITHUB_TOKEN', + [string] $GitHubTagName, + [string] $GitHubReleaseName, + [switch] $GenerateReleaseNotes = $true, + [switch] $IsPreRelease +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Build-PowerForge.ps1' +$invokeParams = @{ + Tool = 'PowerForgeWeb' + Runtime = $Runtime + Configuration = $Configuration + Framework = $Framework + Flavor = $Flavor + UseStaging = $UseStaging + GitHubUsername = $GitHubUsername + GitHubRepositoryName = $GitHubRepositoryName + GitHubAccessTokenEnvName = $GitHubAccessTokenEnvName + GenerateReleaseNotes = $GenerateReleaseNotes + IsPreRelease = $IsPreRelease +} + +if ($PSBoundParameters.ContainsKey('OutDir')) { $invokeParams.OutDir = $OutDir } +if ($ClearOut) { $invokeParams.ClearOut = $true } +if ($Zip) { $invokeParams.Zip = $true } +if ($KeepSymbols) { $invokeParams.KeepSymbols = $true } +if ($KeepDocs) { $invokeParams.KeepDocs = $true } +if ($PublishGitHub) { $invokeParams.PublishGitHub = $true } +if ($PSBoundParameters.ContainsKey('GitHubAccessToken')) { $invokeParams.GitHubAccessToken = $GitHubAccessToken } +if ($PSBoundParameters.ContainsKey('GitHubAccessTokenFilePath')) { $invokeParams.GitHubAccessTokenFilePath = $GitHubAccessTokenFilePath } +if ($PSBoundParameters.ContainsKey('GitHubTagName')) { $invokeParams.GitHubTagName = $GitHubTagName } +if ($PSBoundParameters.ContainsKey('GitHubReleaseName')) { $invokeParams.GitHubReleaseName = $GitHubReleaseName } + +& $scriptPath @invokeParams +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/README.MD b/README.MD index 66866583..707176a3 100644 --- a/README.MD +++ b/README.MD @@ -218,6 +218,19 @@ For release/build packaging, this repo now also ships a standard project-build e .\Build\Build-Project.ps1 -PublishNuget $true -PublishGitHub $true ``` +For downloadable CLI binaries, use the tool release builders: + +```powershell +# Build PowerForge.exe / PowerForge for one runtime +.\Build\Build-PowerForge.ps1 -Tool PowerForge -Runtime win-x64 -Framework net8.0 -Flavor SingleFx -Zip + +# Build PowerForgeWeb.exe / PowerForgeWeb +.\Build\Build-PowerForgeWeb.ps1 -Runtime win-x64 -Framework net8.0 -Flavor SingleFx -Zip + +# Optional: publish the generated zip assets to GitHub releases +.\Build\Build-PowerForge.ps1 -Tool All -Runtime win-x64,linux-x64,osx-arm64 -Framework net8.0 -Flavor SingleFx -Zip -PublishGitHub +``` + Introduced in **1.0.0** a new way to build PowerShell module based on DSL language. ```powershell From e7c5766ceb7b1762b955b289d89ad34af3270f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 13 Mar 2026 08:35:03 +0100 Subject: [PATCH 6/7] Unify package and tool release pipeline --- Build/Build-PowerForge.ps1 | 416 +---------- Build/Build-PowerForgeWeb.ps1 | 54 +- Build/Build-Project.ps1 | 54 +- Build/release.json | 85 +++ Docs/PSPublishModule.ProjectBuild.md | 7 + PowerForge.Cli/PowerForgeCliJsonContext.cs | 7 + PowerForge.Cli/Program.Command.Release.cs | 178 +++++ PowerForge.Cli/Program.Helpers.IOAndJson.cs | 34 + PowerForge.Cli/Program.Helpers.cs | 3 + PowerForge.Cli/Program.cs | 2 + .../PowerForgeReleaseServiceTests.cs | 198 ++++++ PowerForge/InternalsVisibleTo.cs | 2 + PowerForge/Models/PowerForgeRelease.cs | 94 +++ PowerForge/Models/PowerForgeToolRelease.cs | 218 ++++++ .../Models/ProjectBuildHostExecutionResult.cs | 57 ++ PowerForge/Models/ProjectBuildHostRequest.cs | 48 ++ .../Services/PowerForgeReleaseService.cs | 286 ++++++++ .../Services/PowerForgeToolReleaseService.cs | 650 ++++++++++++++++++ .../Services/ProjectBuildHostService.cs | 128 ++++ README.MD | 13 +- Schemas/powerforge.release.schema.json | 70 ++ 21 files changed, 2142 insertions(+), 462 deletions(-) create mode 100644 Build/release.json create mode 100644 PowerForge.Cli/Program.Command.Release.cs create mode 100644 PowerForge.Tests/PowerForgeReleaseServiceTests.cs create mode 100644 PowerForge/Models/PowerForgeRelease.cs create mode 100644 PowerForge/Models/PowerForgeToolRelease.cs create mode 100644 PowerForge/Models/ProjectBuildHostExecutionResult.cs create mode 100644 PowerForge/Models/ProjectBuildHostRequest.cs create mode 100644 PowerForge/Services/PowerForgeReleaseService.cs create mode 100644 PowerForge/Services/PowerForgeToolReleaseService.cs create mode 100644 PowerForge/Services/ProjectBuildHostService.cs create mode 100644 Schemas/powerforge.release.schema.json diff --git a/Build/Build-PowerForge.ps1 b/Build/Build-PowerForge.ps1 index f44b096f..ac370df3 100644 --- a/Build/Build-PowerForge.ps1 +++ b/Build/Build-PowerForge.ps1 @@ -3,413 +3,35 @@ [string[]] $Tool = @('PowerForge'), [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'osx-x64', 'osx-arm64')] [string[]] $Runtime = @('win-x64'), - [ValidateSet('Debug', 'Release')] - [string] $Configuration = 'Release', [ValidateSet('net10.0', 'net8.0')] [string] $Framework = 'net10.0', [ValidateSet('SingleContained', 'SingleFx', 'Portable', 'Fx')] [string] $Flavor = 'SingleContained', - [string] $OutDir, - [switch] $ClearOut, - [switch] $Zip, - [switch] $UseStaging = $true, - [switch] $KeepSymbols, - [switch] $KeepDocs, + [switch] $Plan, + [switch] $Validate, [switch] $PublishGitHub, - [string] $GitHubUsername = 'EvotecIT', - [string] $GitHubRepositoryName = 'PSPublishModule', - [string] $GitHubAccessToken, - [string] $GitHubAccessTokenFilePath, - [string] $GitHubAccessTokenEnvName = 'GITHUB_TOKEN', - [string] $GitHubTagName, - [string] $GitHubReleaseName, - [switch] $GenerateReleaseNotes = $true, - [switch] $IsPreRelease + [string] $ConfigPath ) -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -function Write-Header($Text) { Write-Host "`n=== $Text ===" -ForegroundColor Cyan } -function Write-Step($Text) { Write-Host "-> $Text" -ForegroundColor Yellow } -function Write-Ok($Text) { Write-Host "[ok] $Text" -ForegroundColor Green } - -$repoRoot = (Resolve-Path -LiteralPath ([IO.Path]::GetFullPath([IO.Path]::Combine($PSScriptRoot, '..')))).Path -$moduleManifest = Join-Path $repoRoot 'PSPublishModule\PSPublishModule.psd1' -$outDirProvided = $PSBoundParameters.ContainsKey('OutDir') -and -not [string]::IsNullOrWhiteSpace($OutDir) - -if ($PublishGitHub) { - $Zip = $true -} - -$toolDefinitions = @{ - PowerForge = @{ - ProjectPath = Join-Path $repoRoot 'PowerForge.Cli\PowerForge.Cli.csproj' - ArtifactRoot = Join-Path $repoRoot 'Artifacts\PowerForge' - OutputName = 'PowerForge' - OutputNameLower = 'powerforge' - PublishedBinaryCandidates = @('PowerForge.Cli.exe', 'PowerForge.Cli') - } - PowerForgeWeb = @{ - ProjectPath = Join-Path $repoRoot 'PowerForge.Web.Cli\PowerForge.Web.Cli.csproj' - ArtifactRoot = Join-Path $repoRoot 'Artifacts\PowerForgeWeb' - OutputName = 'PowerForgeWeb' - OutputNameLower = 'powerforge-web' - PublishedBinaryCandidates = @('PowerForge.Web.Cli.exe', 'PowerForge.Web.Cli') - } -} - -function Resolve-ToolSelection { - param([string[]] $SelectedTools) - - $normalized = @( - @($SelectedTools) | - Where-Object { $_ -and $_.Trim() } | - ForEach-Object { $_.Trim() } | - Select-Object -Unique - ) - - if ($normalized.Count -eq 0) { - throw 'Tool selection cannot be empty.' - } - - if ($normalized -contains 'All') { - return @('PowerForge', 'PowerForgeWeb') - } - - return $normalized -} - -function Resolve-ProjectVersion { - param([Parameter(Mandatory)][string] $ProjectPath) - - [xml] $xml = Get-Content -LiteralPath $ProjectPath -Raw - $node = $xml.SelectSingleNode("/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='Version']") - if (-not $node) { - $node = $xml.SelectSingleNode("/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='VersionPrefix']") - } - - if (-not $node -or [string]::IsNullOrWhiteSpace($node.InnerText)) { - throw "Unable to resolve Version/VersionPrefix from $ProjectPath" - } - - return $node.InnerText.Trim() -} - -function Resolve-OutDir { - param( - [Parameter(Mandatory)][string] $ToolName, - [Parameter(Mandatory)][string] $Rid, - [Parameter(Mandatory)][string] $DefaultRoot, - [Parameter(Mandatory)][bool] $MultiTool, - [Parameter(Mandatory)][bool] $MultiRuntime - ) - - if ($outDirProvided) { - $root = $OutDir - if ($MultiTool) { - $root = Join-Path $root $ToolName - } - if ($MultiRuntime) { - return Join-Path $root ("{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) - } - return $root - } - - return Join-Path $DefaultRoot ("{0}/{1}/{2}" -f $Rid, $Framework, $Flavor) -} - -function Resolve-ToolOutputRoot { - param( - [Parameter(Mandatory)][string] $ToolName, - [Parameter(Mandatory)][string] $DefaultRoot, - [Parameter(Mandatory)][bool] $MultiTool - ) - - if ($outDirProvided) { - if ($MultiTool) { - return Join-Path $OutDir $ToolName - } - - return $OutDir - } - - return $DefaultRoot -} - -function Remove-DirectoryContents { - param([Parameter(Mandatory)][string] $Path) - - if (-not (Test-Path -LiteralPath $Path)) { - return - } - - Get-ChildItem -Path $Path -Force -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -} - -function Resolve-GitHubToken { - if (-not $PublishGitHub) { - return $null - } - - if (-not [string]::IsNullOrWhiteSpace($GitHubAccessToken)) { - return $GitHubAccessToken.Trim() - } - - if (-not [string]::IsNullOrWhiteSpace($GitHubAccessTokenFilePath)) { - $tokenPath = if ([IO.Path]::IsPathRooted($GitHubAccessTokenFilePath)) { - $GitHubAccessTokenFilePath - } else { - Join-Path $repoRoot $GitHubAccessTokenFilePath - } - - if (Test-Path -LiteralPath $tokenPath) { - return (Get-Content -LiteralPath $tokenPath -Raw).Trim() - } - } - - if (-not [string]::IsNullOrWhiteSpace($GitHubAccessTokenEnvName)) { - $envToken = [Environment]::GetEnvironmentVariable($GitHubAccessTokenEnvName) - if (-not [string]::IsNullOrWhiteSpace($envToken)) { - return $envToken.Trim() - } - } - - throw 'GitHub token is required when -PublishGitHub is used.' +if (-not $PSBoundParameters.ContainsKey('ConfigPath') -or [string]::IsNullOrWhiteSpace($ConfigPath)) { + $ConfigPath = Join-Path $PSScriptRoot 'release.json' } -function Publish-GitHubAssets { - param( - [Parameter(Mandatory)][string] $ToolName, - [Parameter(Mandatory)][string] $Version, - [Parameter(Mandatory)][string[]] $AssetPaths, - [Parameter(Mandatory)][string] $Token - ) - - if ($AssetPaths.Count -eq 0) { - throw "No assets were created for $ToolName, so there is nothing to publish." - } - - Import-Module $moduleManifest -Force -ErrorAction Stop - - $tagName = if (-not [string]::IsNullOrWhiteSpace($GitHubTagName) -and $selectedTools.Count -eq 1) { - $GitHubTagName.Trim() - } else { - "$ToolName-v$Version" - } - - $releaseName = if (-not [string]::IsNullOrWhiteSpace($GitHubReleaseName) -and $selectedTools.Count -eq 1) { - $GitHubReleaseName.Trim() - } else { - "$ToolName $Version" - } - - Write-Step "Publishing $ToolName assets to GitHub release $tagName" - $publishResult = Send-GitHubRelease ` - -GitHubUsername $GitHubUsername ` - -GitHubRepositoryName $GitHubRepositoryName ` - -GitHubAccessToken $Token ` - -TagName $tagName ` - -ReleaseName $releaseName ` - -GenerateReleaseNotes:$GenerateReleaseNotes ` - -IsPreRelease:$IsPreRelease ` - -AssetFilePaths $AssetPaths - - if (-not $publishResult.Succeeded) { - throw "GitHub release publish failed for ${ToolName}: $($publishResult.ErrorMessage)" - } - - Write-Ok "$ToolName release published -> $($publishResult.ReleaseUrl)" +if ($Tool -contains 'All') { + $Tool = @('PowerForge', 'PowerForgeWeb') } -$selectedTools = @(Resolve-ToolSelection -SelectedTools $Tool) -$multiTool = $selectedTools.Count -gt 1 -$rids = @( - @($Runtime) | - Where-Object { $_ -and $_.Trim() } | - ForEach-Object { $_.Trim() } | - Select-Object -Unique -) -if ($rids.Count -eq 0) { - throw 'Runtime must not be empty.' +$buildProjectParams = @{ + ConfigPath = $ConfigPath + ToolsOnly = $true + Target = $Tool + Runtime = $Runtime + Framework = @($Framework) + Flavor = @($Flavor) } -$multiRuntime = $rids.Count -gt 1 -$singleFile = $Flavor -in @('SingleContained', 'SingleFx') -$selfContained = $Flavor -in @('SingleContained', 'Portable') -$compress = $singleFile -$selfExtract = $Flavor -eq 'SingleContained' -$gitHubToken = Resolve-GitHubToken -$publishedAssets = @{} - -Write-Header "Build tools ($Flavor)" -Write-Step "Framework -> $Framework" -Write-Step "Configuration -> $Configuration" -Write-Step ("Tools -> {0}" -f ($selectedTools -join ', ')) -Write-Step ("Runtimes -> {0}" -f ($rids -join ', ')) - -foreach ($toolName in $selectedTools) { - $definition = $toolDefinitions[$toolName] - if (-not $definition) { - throw "Unsupported tool: $toolName" - } - - $projectPath = [string] $definition.ProjectPath - if (-not (Test-Path -LiteralPath $projectPath)) { - throw "Project not found: $projectPath" - } - - $artifactRoot = [string] $definition.ArtifactRoot - $toolOutputRoot = Resolve-ToolOutputRoot -ToolName $toolName -DefaultRoot $artifactRoot -MultiTool:$multiTool - $outputName = [string] $definition.OutputName - $lowerAlias = [string] $definition.OutputNameLower - $candidateNames = @($definition.PublishedBinaryCandidates) - $version = Resolve-ProjectVersion -ProjectPath $projectPath - $publishedAssets[$toolName] = @() - - foreach ($rid in $rids) { - $outDirThis = Resolve-OutDir -ToolName $toolName -Rid $rid -DefaultRoot $artifactRoot -MultiTool:$multiTool -MultiRuntime:$multiRuntime - New-Item -ItemType Directory -Force -Path $outDirThis | Out-Null - - $publishDir = $outDirThis - $stagingDir = $null - if ($UseStaging) { - $stagingDir = Join-Path $env:TEMP ($outputName + '.publish.' + [guid]::NewGuid().ToString('N')) - $publishDir = $stagingDir - Write-Step "Using staging publish dir -> $publishDir" - if (Test-Path -LiteralPath $publishDir) { - Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue - } - New-Item -ItemType Directory -Force -Path $publishDir | Out-Null - } - - Write-Step "$toolName runtime -> $rid" - Write-Step "Publishing -> $publishDir" - - $publishArgs = @( - 'publish', $projectPath, - '-c', $Configuration, - '-f', $Framework, - '-r', $rid, - "--self-contained:$selfContained", - "/p:PublishSingleFile=$singleFile", - "/p:PublishReadyToRun=false", - "/p:PublishTrimmed=false", - "/p:IncludeAllContentForSelfExtract=$selfExtract", - "/p:IncludeNativeLibrariesForSelfExtract=$selfExtract", - "/p:EnableCompressionInSingleFile=$compress", - "/p:EnableSingleFileAnalyzer=false", - "/p:DebugType=None", - "/p:DebugSymbols=false", - "/p:GenerateDocumentationFile=false", - "/p:CopyDocumentationFiles=false", - "/p:ExcludeSymbolsFromSingleFile=true", - "/p:ErrorOnDuplicatePublishOutputFiles=false", - "/p:UseAppHost=true", - "/p:PublishDir=$publishDir" - ) - - if ($ClearOut -and (Test-Path -LiteralPath $outDirThis) -and ($publishDir -eq $outDirThis)) { - Write-Step "Clearing $outDirThis" - Remove-DirectoryContents -Path $outDirThis - } +if ($Plan) { $buildProjectParams.Plan = $true } +if ($Validate) { $buildProjectParams.Validate = $true } +if ($PublishGitHub) { $buildProjectParams.PublishToolGitHub = $true } - dotnet @publishArgs - if ($LASTEXITCODE -ne 0) { - throw "Publish failed for $toolName ($LASTEXITCODE)" - } - - if (-not $KeepSymbols) { - Write-Step "Removing symbols (*.pdb)" - Get-ChildItem -Path $publishDir -Filter *.pdb -File -Recurse -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - } - - if (-not $KeepDocs) { - Write-Step "Removing docs (*.xml, *.pdf)" - Get-ChildItem -Path $publishDir -File -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.Extension -in @('.xml', '.pdf') } | - Remove-Item -Force -ErrorAction SilentlyContinue - } - - $ridIsWindows = $rid -like 'win-*' - $friendlyBinaryName = if ($ridIsWindows) { $outputName + '.exe' } else { $outputName } - $friendlyBinary = Join-Path $publishDir $friendlyBinaryName - foreach ($candidateName in $candidateNames) { - $candidatePath = Join-Path $publishDir $candidateName - if (-not (Test-Path -LiteralPath $candidatePath)) { - continue - } - - if (Test-Path -LiteralPath $friendlyBinary) { - Remove-Item -LiteralPath $friendlyBinary -Force -ErrorAction SilentlyContinue - } - - Move-Item -LiteralPath $candidatePath -Destination $friendlyBinary -Force - break - } - - if (-not (Test-Path -LiteralPath $friendlyBinary)) { - throw "Friendly output binary was not created for $toolName ($rid): $friendlyBinary" - } - - if ($ClearOut -and (Test-Path -LiteralPath $outDirThis) -and ($publishDir -ne $outDirThis)) { - Write-Step "Clearing $outDirThis" - Remove-DirectoryContents -Path $outDirThis - } - - if ($publishDir -ne $outDirThis) { - Write-Step "Copying publish output -> $outDirThis" - Copy-Item -Path (Join-Path $publishDir '*') -Destination $outDirThis -Recurse -Force - } - - if ($Zip) { - $zipName = "{0}-{1}-{2}-{3}-{4}.zip" -f $outputName, $version, $Framework, $rid, $Flavor - $zipPath = Join-Path (Split-Path -Parent $outDirThis) $zipName - if (Test-Path -LiteralPath $zipPath) { - Remove-Item -LiteralPath $zipPath -Force - } - Write-Step "Create zip -> $zipPath" - Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::CreateFromDirectory($outDirThis, $zipPath) - $publishedAssets[$toolName] += $zipPath - } - - if ($rid -notlike 'win-*') { - $lowerAliasPath = Join-Path $outDirThis $lowerAlias - $friendlyPublishedBinary = Join-Path $outDirThis $outputName - if ((Test-Path -LiteralPath $friendlyPublishedBinary) -and -not (Test-Path -LiteralPath $lowerAliasPath)) { - Copy-Item -LiteralPath $friendlyPublishedBinary -Destination $lowerAliasPath -Force - } - } - - if ($stagingDir -and (Test-Path -LiteralPath $stagingDir)) { - Remove-Item -Path $stagingDir -Recurse -Force -ErrorAction SilentlyContinue - } - } - - if (-not (Test-Path -LiteralPath $toolOutputRoot)) { - New-Item -ItemType Directory -Force -Path $toolOutputRoot | Out-Null - } - - $manifestPath = Join-Path $toolOutputRoot 'release-manifest.json' - $manifest = [ordered]@{ - Tool = $toolName - Version = $version - Framework = $Framework - Flavor = $Flavor - Runtimes = $rids - Assets = @($publishedAssets[$toolName]) - } | ConvertTo-Json -Depth 5 - Set-Content -LiteralPath $manifestPath -Value $manifest - Write-Ok "$toolName artifacts -> $toolOutputRoot" - - if ($PublishGitHub) { - Publish-GitHubAssets -ToolName $toolName -Version $version -AssetPaths @($publishedAssets[$toolName]) -Token $gitHubToken - } -} - -if ($multiTool) { - $root = if ($outDirProvided) { $OutDir } else { Join-Path $repoRoot 'Artifacts' } - Write-Ok "Built tool artifacts -> $root" -} +& (Join-Path $PSScriptRoot 'Build-Project.ps1') @buildProjectParams +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/Build/Build-PowerForgeWeb.ps1 b/Build/Build-PowerForgeWeb.ps1 index 1923b221..8a1190de 100644 --- a/Build/Build-PowerForgeWeb.ps1 +++ b/Build/Build-PowerForgeWeb.ps1 @@ -1,60 +1,30 @@ [CmdletBinding()] param( [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'osx-x64', 'osx-arm64')] [string[]] $Runtime = @('win-x64'), - [ValidateSet('Debug', 'Release')] - [string] $Configuration = 'Release', [ValidateSet('net10.0', 'net8.0')] [string] $Framework = 'net10.0', [ValidateSet('SingleContained', 'SingleFx', 'Portable', 'Fx')] [string] $Flavor = 'SingleContained', - [string] $OutDir, - [switch] $ClearOut, - [switch] $Zip, - [switch] $UseStaging = $true, - [switch] $KeepSymbols, - [switch] $KeepDocs, + [switch] $Plan, + [switch] $Validate, [switch] $PublishGitHub, - [string] $GitHubUsername = 'EvotecIT', - [string] $GitHubRepositoryName = 'PSPublishModule', - [string] $GitHubAccessToken, - [string] $GitHubAccessTokenFilePath, - [string] $GitHubAccessTokenEnvName = 'GITHUB_TOKEN', - [string] $GitHubTagName, - [string] $GitHubReleaseName, - [switch] $GenerateReleaseNotes = $true, - [switch] $IsPreRelease + [string] $ConfigPath ) -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' +if (-not $PSBoundParameters.ContainsKey('ConfigPath') -or [string]::IsNullOrWhiteSpace($ConfigPath)) { + $ConfigPath = Join-Path $PSScriptRoot 'release.json' +} -$scriptPath = Join-Path $PSScriptRoot 'Build-PowerForge.ps1' $invokeParams = @{ - Tool = 'PowerForgeWeb' + Tool = @('PowerForgeWeb') + ConfigPath = $ConfigPath Runtime = $Runtime - Configuration = $Configuration Framework = $Framework Flavor = $Flavor - UseStaging = $UseStaging - GitHubUsername = $GitHubUsername - GitHubRepositoryName = $GitHubRepositoryName - GitHubAccessTokenEnvName = $GitHubAccessTokenEnvName - GenerateReleaseNotes = $GenerateReleaseNotes - IsPreRelease = $IsPreRelease } - -if ($PSBoundParameters.ContainsKey('OutDir')) { $invokeParams.OutDir = $OutDir } -if ($ClearOut) { $invokeParams.ClearOut = $true } -if ($Zip) { $invokeParams.Zip = $true } -if ($KeepSymbols) { $invokeParams.KeepSymbols = $true } -if ($KeepDocs) { $invokeParams.KeepDocs = $true } +if ($Plan) { $invokeParams.Plan = $true } +if ($Validate) { $invokeParams.Validate = $true } if ($PublishGitHub) { $invokeParams.PublishGitHub = $true } -if ($PSBoundParameters.ContainsKey('GitHubAccessToken')) { $invokeParams.GitHubAccessToken = $GitHubAccessToken } -if ($PSBoundParameters.ContainsKey('GitHubAccessTokenFilePath')) { $invokeParams.GitHubAccessTokenFilePath = $GitHubAccessTokenFilePath } -if ($PSBoundParameters.ContainsKey('GitHubTagName')) { $invokeParams.GitHubTagName = $GitHubTagName } -if ($PSBoundParameters.ContainsKey('GitHubReleaseName')) { $invokeParams.GitHubReleaseName = $GitHubReleaseName } -& $scriptPath @invokeParams -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE -} +& (Join-Path $PSScriptRoot 'Build-PowerForge.ps1') @invokeParams +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/Build/Build-Project.ps1 b/Build/Build-Project.ps1 index a4746073..7fff9e69 100644 --- a/Build/Build-Project.ps1 +++ b/Build/Build-Project.ps1 @@ -1,23 +1,41 @@ param( - [string] $ConfigPath = "$PSScriptRoot\project.build.json", - [Nullable[bool]] $UpdateVersions, - [Nullable[bool]] $Build, - [Nullable[bool]] $PublishNuget = $false, - [Nullable[bool]] $PublishGitHub = $false, - [Nullable[bool]] $Plan, - [string] $PlanPath + [string] $ConfigPath, + [switch] $Plan, + [switch] $Validate, + [switch] $PackagesOnly, + [switch] $ToolsOnly, + [switch] $PublishNuget, + [switch] $PublishGitHub, + [switch] $PublishToolGitHub, + [string[]] $Target, + [string[]] $Runtime, + [string[]] $Framework, + [ValidateSet('SingleContained', 'SingleFx', 'Portable', 'Fx')] + [string[]] $Flavor ) -Import-Module PSPublishModule -Force -ErrorAction Stop - -$invokeParams = @{ - ConfigPath = $ConfigPath +if (-not $PSBoundParameters.ContainsKey('ConfigPath') -or [string]::IsNullOrWhiteSpace($ConfigPath)) { + $ConfigPath = Join-Path $PSScriptRoot 'release.json' } -if ($null -ne $UpdateVersions) { $invokeParams.UpdateVersions = $UpdateVersions } -if ($null -ne $Build) { $invokeParams.Build = $Build } -if ($null -ne $PublishNuget) { $invokeParams.PublishNuget = $PublishNuget } -if ($null -ne $PublishGitHub) { $invokeParams.PublishGitHub = $PublishGitHub } -if ($null -ne $Plan) { $invokeParams.Plan = $Plan } -if ($PlanPath) { $invokeParams.PlanPath = $PlanPath } -Invoke-ProjectBuild @invokeParams +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$project = Join-Path $repoRoot 'PowerForge.Cli\PowerForge.Cli.csproj' + +$args = @( + 'run', '--project', $project, '-c', 'Release', '--framework', 'net10.0', '--no-launch-profile', '--', + 'release', '--config', $ConfigPath +) +if ($Plan) { $args += '--plan' } +if ($Validate) { $args += '--validate' } +if ($PackagesOnly) { $args += '--packages-only' } +if ($ToolsOnly) { $args += '--tools-only' } +if ($PublishNuget) { $args += '--publish-nuget' } +if ($PublishGitHub) { $args += '--publish-project-github' } +if ($PublishToolGitHub) { $args += '--publish-tool-github' } +foreach ($entry in $Target) { $args += @('--target', $entry) } +foreach ($entry in $Runtime) { $args += @('--rid', $entry) } +foreach ($entry in $Framework) { $args += @('--framework', $entry) } +foreach ($entry in $Flavor) { $args += @('--flavor', $entry) } + +dotnet @args +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/Build/release.json b/Build/release.json new file mode 100644 index 00000000..d1fd5342 --- /dev/null +++ b/Build/release.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/powerforge.release.schema.json", + "SchemaVersion": 1, + "Packages": { + "RootPath": "..", + "ExpectedVersionMap": { + "PowerForge": "1.0.X", + "PowerForge.PowerShell": "1.0.X", + "PowerForge.Cli": "1.0.X", + "PowerForge.Blazor": "1.0.X", + "PowerForge.Web": "1.0.X", + "PowerForge.Web.Cli": "1.0.X" + }, + "ExpectedVersionMapAsInclude": true, + "ExpectedVersionMapUseWildcards": false, + "Configuration": "Release", + "StagingPath": "Artefacts/ProjectBuild", + "CleanStaging": true, + "PlanOutputPath": "Artefacts/ProjectBuild/project.build.plan.json", + "UpdateVersions": true, + "Build": true, + "PublishNuget": false, + "PublishGitHub": false, + "CreateReleaseZip": true, + "CertificateThumbprint": "483292C9E317AA13B07BB7A96AE9D1A5ED9E7703", + "CertificateStore": "CurrentUser", + "TimeStampServer": "http://timestamp.digicert.com", + "PublishSource": "https://api.nuget.org/v3/index.json", + "PublishApiKeyFilePath": "C:\\Support\\Important\\NugetOrgEvotec.txt", + "SkipDuplicate": true, + "PublishFailFast": true, + "GitHubAccessTokenFilePath": "C:\\Support\\Important\\GithubAPI.txt", + "GitHubUsername": "EvotecIT", + "GitHubRepositoryName": "PSPublishModule", + "GitHubIsPreRelease": false, + "GitHubIncludeProjectNameInTag": false, + "GitHubGenerateReleaseNotes": true, + "GitHubReleaseMode": "Single", + "GitHubPrimaryProject": "PowerForge.Cli", + "GitHubTagTemplate": "{Repo}-v{PrimaryVersion}", + "GitHubReleaseName": "{Repo} {PrimaryVersion}" + }, + "Tools": { + "ProjectRoot": "..", + "Configuration": "Release", + "Targets": [ + { + "Name": "PowerForge", + "ProjectPath": "PowerForge.Cli/PowerForge.Cli.csproj", + "OutputName": "PowerForge", + "CommandAlias": "powerforge", + "Runtimes": [ "win-x64", "linux-x64", "linux-arm64", "osx-x64", "osx-arm64" ], + "Frameworks": [ "net10.0" ], + "Flavor": "SingleContained", + "ArtifactRootPath": "Artifacts/PowerForge", + "Zip": true, + "UseStaging": true, + "ClearOutput": true + }, + { + "Name": "PowerForgeWeb", + "ProjectPath": "PowerForge.Web.Cli/PowerForge.Web.Cli.csproj", + "OutputName": "PowerForgeWeb", + "CommandAlias": "powerforge-web", + "Runtimes": [ "win-x64", "linux-x64", "linux-arm64", "osx-x64", "osx-arm64" ], + "Frameworks": [ "net10.0" ], + "Flavor": "SingleContained", + "ArtifactRootPath": "Artifacts/PowerForgeWeb", + "Zip": true, + "UseStaging": true, + "ClearOutput": true + } + ], + "GitHub": { + "Publish": false, + "Owner": "EvotecIT", + "Repository": "PSPublishModule", + "TokenFilePath": "C:\\Support\\Important\\GithubAPI.txt", + "GenerateReleaseNotes": true, + "IsPreRelease": false, + "TagTemplate": "{Target}-v{Version}", + "ReleaseNameTemplate": "{Target} {Version}" + } + } +} diff --git a/Docs/PSPublishModule.ProjectBuild.md b/Docs/PSPublishModule.ProjectBuild.md index ab83b539..1f67820e 100644 --- a/Docs/PSPublishModule.ProjectBuild.md +++ b/Docs/PSPublishModule.ProjectBuild.md @@ -1,6 +1,8 @@ # Project Build (Repository Pipeline) This document describes the JSON configuration consumed by `Invoke-ProjectBuild` and the behavior it drives. +For the unified repo-level entrypoint that combines package and downloadable tool releases in one file, +see `Build/release.json` and `powerforge release`. For module help/docs generation workflow (`Invoke-ModuleBuild`, `New-ConfigurationDocumentation`, `about_*` topics), see `Docs/PSPublishModule.ModuleDocumentation.md`. @@ -8,6 +10,11 @@ see `Docs/PSPublishModule.ModuleDocumentation.md`. Schema - Location: `Schemas/project.build.schema.json` +Unified release entrypoint +- Schema: `Schemas/powerforge.release.schema.json` +- Wrapper: `Build/Build-Project.ps1` +- CLI: `powerforge release --config .\Build\release.json` + Overview - The build pipeline discovers .NET projects, resolves versions, optionally updates csproj files, packs and signs NuGet packages, and can publish to NuGet and GitHub. diff --git a/PowerForge.Cli/PowerForgeCliJsonContext.cs b/PowerForge.Cli/PowerForgeCliJsonContext.cs index a1686c61..84cd0cba 100644 --- a/PowerForge.Cli/PowerForgeCliJsonContext.cs +++ b/PowerForge.Cli/PowerForgeCliJsonContext.cs @@ -38,6 +38,13 @@ namespace PowerForge.Cli; [JsonSerializable(typeof(GitHubArtifactCleanupResult))] [JsonSerializable(typeof(GitHubActionsCacheCleanupResult))] [JsonSerializable(typeof(RunnerHousekeepingResult))] +[JsonSerializable(typeof(PowerForgeReleaseSpec))] +[JsonSerializable(typeof(PowerForgeReleaseResult))] +[JsonSerializable(typeof(PowerForgeReleaseRequest))] +[JsonSerializable(typeof(PowerForgeToolReleaseSpec))] +[JsonSerializable(typeof(PowerForgeToolReleasePlan))] +[JsonSerializable(typeof(PowerForgeToolReleaseResult))] +[JsonSerializable(typeof(PowerForgeToolGitHubReleaseResult))] [JsonSerializable(typeof(ArtefactBuildResult[]))] [JsonSerializable(typeof(NormalizationResult[]))] [JsonSerializable(typeof(FormatterResult[]))] diff --git a/PowerForge.Cli/Program.Command.Release.cs b/PowerForge.Cli/Program.Command.Release.cs new file mode 100644 index 00000000..cad9a67c --- /dev/null +++ b/PowerForge.Cli/Program.Command.Release.cs @@ -0,0 +1,178 @@ +using PowerForge; +using PowerForge.Cli; + +internal static partial class Program +{ + private const string ReleaseUsage = + "Usage: powerforge release [--config ] [--plan] [--validate] [--packages-only] [--tools-only] [--publish-nuget] [--publish-project-github] [--publish-tool-github] [--target ] [--rid ] [--framework ] [--flavor [,<...>]] [--output json]"; + + private static int CommandRelease(string[] filteredArgs, CliOptions cli, ILogger logger) + { + var argv = filteredArgs.Skip(1).ToArray(); + var outputJson = IsJsonOutput(argv); + var planOnly = argv.Any(a => a.Equals("--plan", StringComparison.OrdinalIgnoreCase) || a.Equals("--dry-run", StringComparison.OrdinalIgnoreCase)); + var validateOnly = argv.Any(a => a.Equals("--validate", StringComparison.OrdinalIgnoreCase)); + var packagesOnly = argv.Any(a => a.Equals("--packages-only", StringComparison.OrdinalIgnoreCase)); + var toolsOnly = argv.Any(a => a.Equals("--tools-only", StringComparison.OrdinalIgnoreCase)); + + if (packagesOnly && toolsOnly) + { + return WriteReleaseError(outputJson, "release", 2, "Use either --packages-only or --tools-only, not both.", logger); + } + + var configPath = TryGetOptionValue(argv, "--config"); + if (string.IsNullOrWhiteSpace(configPath)) + configPath = FindDefaultReleaseConfig(Directory.GetCurrentDirectory()); + + if (string.IsNullOrWhiteSpace(configPath)) + { + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = "release", + Success = false, + ExitCode = 2, + Error = "Missing --config and no default release config found." + }); + return 2; + } + + Console.WriteLine(ReleaseUsage); + return 2; + } + + try + { + var (cmdLogger, logBuffer) = CreateCommandLogger(outputJson, cli, logger); + var loaded = LoadPowerForgeReleaseSpecWithPath(configPath); + var spec = loaded.Value; + var fullConfigPath = loaded.FullPath; + + var flavors = ParseCsvOptionValues(argv, "--flavor") + .Select(ParsePowerForgeToolReleaseFlavor) + .Distinct() + .ToArray(); + + var request = new PowerForgeReleaseRequest + { + ConfigPath = fullConfigPath, + PlanOnly = planOnly, + ValidateOnly = validateOnly, + PackagesOnly = packagesOnly, + ToolsOnly = toolsOnly, + PublishNuget = argv.Any(a => a.Equals("--publish-nuget", StringComparison.OrdinalIgnoreCase)) ? true : null, + PublishProjectGitHub = argv.Any(a => a.Equals("--publish-project-github", StringComparison.OrdinalIgnoreCase)) ? true : null, + PublishToolGitHub = argv.Any(a => a.Equals("--publish-tool-github", StringComparison.OrdinalIgnoreCase)) ? true : null, + Targets = ParseCsvOptionValues(argv, "--target"), + Runtimes = ParseCsvOptionValues(argv, "--rid", "--runtime"), + Frameworks = ParseCsvOptionValues(argv, "--framework"), + Flavors = flavors + }; + + var service = new PowerForgeReleaseService(cmdLogger); + var result = RunWithStatus(outputJson, cli, "Running unified release workflow", () => service.Execute(spec, request)); + var exitCode = result.Success ? 0 : 1; + + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = "release", + Success = result.Success, + ExitCode = exitCode, + Error = result.ErrorMessage, + Config = "release", + ConfigPath = fullConfigPath, + Spec = CliJson.SerializeToElement(spec, CliJson.Context.PowerForgeReleaseSpec), + Result = CliJson.SerializeToElement(result, CliJson.Context.PowerForgeReleaseResult), + Logs = LogsToJsonElement(logBuffer) + }); + return exitCode; + } + + if (!result.Success) + { + cmdLogger.Error(result.ErrorMessage ?? "Release workflow failed."); + return exitCode; + } + + if (result.Packages is not null) + { + var release = result.Packages.Result.Release; + var packageCount = release?.Projects?.Count(project => project.IsPackable) ?? 0; + cmdLogger.Success($"Packages: {(planOnly || validateOnly ? "planned" : "completed")} ({packageCount} project(s))."); + if (!string.IsNullOrWhiteSpace(result.Packages.PlanOutputPath)) + cmdLogger.Info($"Package plan: {result.Packages.PlanOutputPath}"); + } + + if (result.ToolPlan is not null) + { + var comboCount = result.ToolPlan.Targets.Sum(target => target.Combinations?.Length ?? 0); + cmdLogger.Success($"Tools: {(planOnly || validateOnly ? "planned" : "completed")} ({comboCount} combination(s))."); + } + + if (result.Tools is not null) + { + foreach (var artefact in result.Tools.Artefacts) + { + cmdLogger.Info($" -> {artefact.Target} {artefact.Framework} {artefact.Runtime} {artefact.Flavor}: {artefact.OutputPath}"); + if (!string.IsNullOrWhiteSpace(artefact.ZipPath)) + cmdLogger.Info($" zip: {artefact.ZipPath}"); + } + + foreach (var manifest in result.Tools.ManifestPaths) + cmdLogger.Info($"Manifest: {manifest}"); + } + + foreach (var release in result.ToolGitHubReleases) + { + if (release.Success) + cmdLogger.Info($"GitHub release -> {release.Target} {release.TagName} {release.ReleaseUrl}"); + else + cmdLogger.Warn($"GitHub release failed for {release.Target}: {release.ErrorMessage}"); + } + + return exitCode; + } + catch (Exception ex) + { + return WriteReleaseError(outputJson, "release", 1, ex.Message, logger); + } + } + + private static int WriteReleaseError(bool outputJson, string command, int exitCode, string message, ILogger logger) + { + if (outputJson) + { + WriteJson(new CliJsonEnvelope + { + SchemaVersion = OutputSchemaVersion, + Command = command, + Success = false, + ExitCode = exitCode, + Error = message + }); + return exitCode; + } + + logger.Error(message); + return exitCode; + } + + private static PowerForgeToolReleaseFlavor ParsePowerForgeToolReleaseFlavor(string? value) + { + var raw = (value ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(raw)) + throw new ArgumentException("Flavor must not be empty.", nameof(value)); + + if (Enum.TryParse(raw, ignoreCase: true, out PowerForgeToolReleaseFlavor flavor)) + return flavor; + + throw new ArgumentException( + $"Unknown tool release flavor: {raw}. Expected one of: {string.Join(", ", Enum.GetNames(typeof(PowerForgeToolReleaseFlavor)))}", + nameof(value)); + } +} diff --git a/PowerForge.Cli/Program.Helpers.IOAndJson.cs b/PowerForge.Cli/Program.Helpers.IOAndJson.cs index b3e12b73..b8383e8d 100644 --- a/PowerForge.Cli/Program.Helpers.IOAndJson.cs +++ b/PowerForge.Cli/Program.Helpers.IOAndJson.cs @@ -181,6 +181,32 @@ static void ResolvePipelineSpecPaths(ModulePipelineSpec spec, string configFullP return null; } + static string? FindDefaultReleaseConfig(string baseDir) + { + var candidates = new[] + { + "powerforge.release.json", + Path.Combine(".powerforge", "release.json"), + Path.Combine("Build", "release.json"), + "release.json" + }; + + foreach (var dir in EnumerateSelfAndParents(baseDir)) + { + foreach (var rel in candidates) + { + try + { + var full = Path.GetFullPath(Path.Combine(dir, rel)); + if (File.Exists(full)) return full; + } + catch { /* ignore */ } + } + } + + return null; + } + static IEnumerable EnumerateSelfAndParents(string? baseDir) { string current; @@ -271,6 +297,14 @@ static string ResolveExistingFilePath(string path) return (spec, full); } + static (PowerForgeReleaseSpec Value, string FullPath) LoadPowerForgeReleaseSpecWithPath(string path) + { + var full = ResolveExistingFilePath(path); + var json = File.ReadAllText(full); + var spec = CliJson.DeserializeOrThrow(json, CliJson.Context.PowerForgeReleaseSpec, full); + return (spec, full); + } + static (ModuleInstallSpec Value, string FullPath) LoadInstallSpecWithPath(string path) { var full = ResolveExistingFilePath(path); diff --git a/PowerForge.Cli/Program.Helpers.cs b/PowerForge.Cli/Program.Helpers.cs index 65b10c58..7bf5a0f2 100644 --- a/PowerForge.Cli/Program.Helpers.cs +++ b/PowerForge.Cli/Program.Helpers.cs @@ -17,6 +17,8 @@ powerforge pack [--config ] [--project-root ] [--out powerforge template --script [--out ] [--project-root ] [--powershell ] [--output json] powerforge dotnet publish [--config ] [--project-root ] [--profile ] [--plan] [--validate] [--output json] [--target ] [--rid ] [--framework ] [--style ] [--matrix ] [--skip-restore] [--skip-build] powerforge dotnet scaffold [--project-root ] [--project ] [--target ] [--framework ] [--rid ] [--style [,...]] [--configuration ] [--out ] [--overwrite] [--no-schema] [--output json] + powerforge release [--config ] [--plan] [--validate] [--packages-only] [--tools-only] [--publish-nuget] [--publish-project-github] [--publish-tool-github] + [--target ] [--rid ] [--framework ] [--flavor [,<...>]] [--output json] powerforge normalize Normalize encodings and line endings [--output json] powerforge format Format scripts via PSScriptAnalyzer (out-of-proc) [--output json] powerforge test [--project-root ] [--test-path ] [--format Detailed|Normal|Minimal] [--coverage] [--force] @@ -53,6 +55,7 @@ Default config discovery (when --config is omitted): Searches for powerforge.json / powerforge.pipeline.json / .powerforge/pipeline.json in the current directory and parent directories. DotNet publish searches for powerforge.dotnetpublish.json / .powerforge/dotnetpublish.json. + Release searches for powerforge.release.json / .powerforge/release.json / Build/release.json. "); } diff --git a/PowerForge.Cli/Program.cs b/PowerForge.Cli/Program.cs index a5956619..0c4a2e6e 100644 --- a/PowerForge.Cli/Program.cs +++ b/PowerForge.Cli/Program.cs @@ -67,6 +67,8 @@ public static int Main(string[] args) return CommandPack(filteredArgs, cli, logger); case "dotnet": return CommandDotNet(filteredArgs, cli, logger); + case "release": + return CommandRelease(filteredArgs, cli, logger); case "github": return CommandGitHub(filteredArgs, cli, logger); case "normalize": diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs new file mode 100644 index 00000000..984ea5c7 --- /dev/null +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs @@ -0,0 +1,198 @@ +using System.Text; + +namespace PowerForge.Tests; + +public sealed class PowerForgeReleaseServiceTests +{ + [Fact] + public void ToolReleasePlan_AppliesOverridesAcrossSelectedTarget() + { + var root = CreateSandbox(); + try + { + var projectPath = Path.Combine(root, "PowerForge.Cli.csproj"); + File.WriteAllText(projectPath, """ + + + net10.0 + 1.2.3 + + +""", new UTF8Encoding(false)); + + var service = new PowerForgeToolReleaseService(new NullLogger()); + var plan = service.Plan( + new PowerForgeToolReleaseSpec + { + ProjectRoot = ".", + Targets = new[] + { + new PowerForgeToolReleaseTarget + { + Name = "PowerForge", + ProjectPath = "PowerForge.Cli.csproj", + OutputName = "PowerForge", + Frameworks = new[] { "net10.0" }, + Runtimes = new[] { "win-x64", "linux-x64" }, + Flavor = PowerForgeToolReleaseFlavor.SingleContained + } + } + }, + Path.Combine(root, "release.json"), + new PowerForgeReleaseRequest + { + Targets = new[] { "PowerForge" }, + Runtimes = new[] { "osx-arm64" }, + Frameworks = new[] { "net8.0" }, + Flavors = new[] { PowerForgeToolReleaseFlavor.SingleFx } + }); + + var target = Assert.Single(plan.Targets); + var combination = Assert.Single(target.Combinations); + Assert.Equal("1.2.3", target.Version); + Assert.Equal("osx-arm64", combination.Runtime); + Assert.Equal("net8.0", combination.Framework); + Assert.Equal(PowerForgeToolReleaseFlavor.SingleFx, combination.Flavor); + Assert.Contains("PowerForge", combination.OutputPath, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Execute_GroupsToolAssetsIntoSingleGitHubReleasePerTarget() + { + var zipA = Path.GetTempFileName(); + var zipB = Path.GetTempFileName(); + try + { + var publishCalls = new List(); + var service = new PowerForgeReleaseService( + new NullLogger(), + executePackages: (_, _, _) => throw new InvalidOperationException("Packages should not run."), + planTools: (_, _, _) => new PowerForgeToolReleasePlan + { + ProjectRoot = Path.GetTempPath(), + Configuration = "Release", + Targets = new[] + { + new PowerForgeToolReleaseTargetPlan + { + Name = "PowerForge", + ProjectPath = "PowerForge.Cli.csproj", + OutputName = "PowerForge", + Version = "1.2.3", + ArtifactRootPath = Path.GetTempPath(), + Combinations = new[] + { + new PowerForgeToolReleaseCombinationPlan + { + Runtime = "win-x64", + Framework = "net10.0", + Flavor = PowerForgeToolReleaseFlavor.SingleContained, + OutputPath = Path.GetTempPath(), + ZipPath = zipA + } + } + } + } + }, + runTools: _ => new PowerForgeToolReleaseResult + { + Success = true, + Artefacts = new[] + { + new PowerForgeToolReleaseArtifactResult + { + Target = "PowerForge", + Version = "1.2.3", + OutputName = "PowerForge", + Runtime = "win-x64", + Framework = "net10.0", + Flavor = PowerForgeToolReleaseFlavor.SingleContained, + OutputPath = Path.GetTempPath(), + ExecutablePath = Path.Combine(Path.GetTempPath(), "PowerForge.exe"), + ZipPath = zipA + }, + new PowerForgeToolReleaseArtifactResult + { + Target = "PowerForge", + Version = "1.2.3", + OutputName = "PowerForge", + Runtime = "linux-x64", + Framework = "net10.0", + Flavor = PowerForgeToolReleaseFlavor.SingleContained, + OutputPath = Path.GetTempPath(), + ExecutablePath = Path.Combine(Path.GetTempPath(), "PowerForge"), + ZipPath = zipB + } + } + }, + publishGitHubRelease: request => + { + publishCalls.Add(request); + return new GitHubReleasePublishResult + { + Succeeded = true, + HtmlUrl = "https://example.test/release", + ReusedExistingRelease = true + }; + }); + + var result = service.Execute( + new PowerForgeReleaseSpec + { + Tools = new PowerForgeToolReleaseSpec + { + GitHub = new PowerForgeToolReleaseGitHubOptions + { + Publish = true, + Owner = "EvotecIT", + Repository = "PSPublishModule", + Token = "token", + TagTemplate = "{Target}-v{Version}", + ReleaseNameTemplate = "{Target} {Version}" + } + } + }, + new PowerForgeReleaseRequest + { + ConfigPath = Path.Combine(Path.GetTempPath(), "release.json"), + ToolsOnly = true + }); + + Assert.True(result.Success); + var publish = Assert.Single(publishCalls); + Assert.Equal("PowerForge-v1.2.3", publish.TagName); + Assert.Equal("PowerForge 1.2.3", publish.ReleaseName); + Assert.Equal(2, publish.AssetFilePaths.Count); + + var release = Assert.Single(result.ToolGitHubReleases); + Assert.True(release.Success); + Assert.Equal(2, release.AssetPaths.Length); + Assert.True(release.ReusedExistingRelease); + } + finally + { + TryDelete(zipA); + TryDelete(zipB); + } + } + + private static string CreateSandbox() + { + var path = Path.Combine(Path.GetTempPath(), "PowerForge.ReleaseTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + else if (File.Exists(path)) + File.Delete(path); + } +} diff --git a/PowerForge/InternalsVisibleTo.cs b/PowerForge/InternalsVisibleTo.cs index 5559c0fb..5612601b 100644 --- a/PowerForge/InternalsVisibleTo.cs +++ b/PowerForge/InternalsVisibleTo.cs @@ -1,5 +1,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("PowerForge.Tests")] +[assembly: InternalsVisibleTo("PowerForge.Cli")] [assembly: InternalsVisibleTo("PowerForge.PowerShell")] +[assembly: InternalsVisibleTo("PowerForgeStudio.Tests")] [assembly: InternalsVisibleTo("PSPublishModule")] diff --git a/PowerForge/Models/PowerForgeRelease.cs b/PowerForge/Models/PowerForgeRelease.cs new file mode 100644 index 00000000..dc718811 --- /dev/null +++ b/PowerForge/Models/PowerForgeRelease.cs @@ -0,0 +1,94 @@ +using System.Text.Json.Serialization; + +namespace PowerForge; + +/// +/// Unified repository release configuration that can drive package and tool outputs from one JSON file. +/// +internal sealed class PowerForgeReleaseSpec +{ + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + + public int SchemaVersion { get; set; } = 1; + + public ProjectBuildConfiguration? Packages { get; set; } + + public PowerForgeToolReleaseSpec? Tools { get; set; } +} + +/// +/// Host-facing request for executing a unified release workflow. +/// +internal sealed class PowerForgeReleaseRequest +{ + public string ConfigPath { get; set; } = string.Empty; + + public bool PlanOnly { get; set; } + + public bool ValidateOnly { get; set; } + + public bool PackagesOnly { get; set; } + + public bool ToolsOnly { get; set; } + + public bool? PublishNuget { get; set; } + + public bool? PublishProjectGitHub { get; set; } + + public bool? PublishToolGitHub { get; set; } + + public string[] Targets { get; set; } = Array.Empty(); + + public string[] Runtimes { get; set; } = Array.Empty(); + + public string[] Frameworks { get; set; } = Array.Empty(); + + public PowerForgeToolReleaseFlavor[] Flavors { get; set; } = Array.Empty(); +} + +/// +/// Aggregate result for a unified release run. +/// +internal sealed class PowerForgeReleaseResult +{ + public bool Success { get; set; } + + public string? ErrorMessage { get; set; } + + public string ConfigPath { get; set; } = string.Empty; + + public ProjectBuildHostExecutionResult? Packages { get; set; } + + public PowerForgeToolReleasePlan? ToolPlan { get; set; } + + public PowerForgeToolReleaseResult? Tools { get; set; } + + public PowerForgeToolGitHubReleaseResult[] ToolGitHubReleases { get; set; } = Array.Empty(); +} + +/// +/// GitHub publishing result for one tool release group. +/// +internal sealed class PowerForgeToolGitHubReleaseResult +{ + public string Target { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string TagName { get; set; } = string.Empty; + + public string ReleaseName { get; set; } = string.Empty; + + public string[] AssetPaths { get; set; } = Array.Empty(); + + public bool Success { get; set; } + + public string? ReleaseUrl { get; set; } + + public bool ReusedExistingRelease { get; set; } + + public string? ErrorMessage { get; set; } + + public string[] SkippedExistingAssets { get; set; } = Array.Empty(); +} diff --git a/PowerForge/Models/PowerForgeToolRelease.cs b/PowerForge/Models/PowerForgeToolRelease.cs new file mode 100644 index 00000000..06e343e2 --- /dev/null +++ b/PowerForge/Models/PowerForgeToolRelease.cs @@ -0,0 +1,218 @@ +using System.Text.Json.Serialization; + +namespace PowerForge; + +/// +/// Downloadable tool release configuration used for runtime-specific executables. +/// +internal sealed class PowerForgeToolReleaseSpec +{ + public string? ProjectRoot { get; set; } + + public string Configuration { get; set; } = "Release"; + + public PowerForgeToolReleaseTarget[] Targets { get; set; } = Array.Empty(); + + public PowerForgeToolReleaseGitHubOptions GitHub { get; set; } = new(); +} + +/// +/// One named tool target to publish as downloadable executables. +/// +internal sealed class PowerForgeToolReleaseTarget +{ + public string Name { get; set; } = string.Empty; + + public string ProjectPath { get; set; } = string.Empty; + + public string OutputName { get; set; } = string.Empty; + + public string? CommandAlias { get; set; } + + public string[] Runtimes { get; set; } = Array.Empty(); + + public string[] Frameworks { get; set; } = Array.Empty(); + + public PowerForgeToolReleaseFlavor Flavor { get; set; } = PowerForgeToolReleaseFlavor.SingleContained; + + public PowerForgeToolReleaseFlavor[] Flavors { get; set; } = Array.Empty(); + + public string? ArtifactRootPath { get; set; } + + public string? OutputPath { get; set; } + + public bool UseStaging { get; set; } = true; + + public bool ClearOutput { get; set; } = true; + + public bool Zip { get; set; } = true; + + public string? ZipPath { get; set; } + + public string? ZipNameTemplate { get; set; } + + public bool KeepSymbols { get; set; } + + public bool KeepDocs { get; set; } + + public bool CreateCommandAliasOnUnix { get; set; } = true; + + public Dictionary? MsBuildProperties { get; set; } +} + +/// +/// GitHub release settings for tool artefacts. +/// +internal sealed class PowerForgeToolReleaseGitHubOptions +{ + public bool Publish { get; set; } + + public string? Owner { get; set; } + + public string? Repository { get; set; } + + public string? Token { get; set; } + + public string? TokenFilePath { get; set; } + + public string? TokenEnvName { get; set; } + + public bool GenerateReleaseNotes { get; set; } = true; + + public bool IsPreRelease { get; set; } + + public string? TagTemplate { get; set; } + + public string? ReleaseNameTemplate { get; set; } +} + +/// +/// Supported publish flavors for downloadable tool binaries. +/// +internal enum PowerForgeToolReleaseFlavor +{ + SingleContained, + SingleFx, + Portable, + Fx +} + +/// +/// Planned tool release execution. +/// +internal sealed class PowerForgeToolReleasePlan +{ + public string ProjectRoot { get; set; } = string.Empty; + + public string Configuration { get; set; } = "Release"; + + public PowerForgeToolReleaseTargetPlan[] Targets { get; set; } = Array.Empty(); +} + +/// +/// Planned target entry. +/// +internal sealed class PowerForgeToolReleaseTargetPlan +{ + public string Name { get; set; } = string.Empty; + + public string ProjectPath { get; set; } = string.Empty; + + public string OutputName { get; set; } = string.Empty; + + public string? CommandAlias { get; set; } + + public string Version { get; set; } = string.Empty; + + public string ArtifactRootPath { get; set; } = string.Empty; + + public bool UseStaging { get; set; } + + public bool ClearOutput { get; set; } + + public bool Zip { get; set; } + + public bool KeepSymbols { get; set; } + + public bool KeepDocs { get; set; } + + public bool CreateCommandAliasOnUnix { get; set; } + + public Dictionary MsBuildProperties { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public PowerForgeToolReleaseCombinationPlan[] Combinations { get; set; } = Array.Empty(); +} + +/// +/// Planned target/runtime/framework/flavor combination. +/// +internal sealed class PowerForgeToolReleaseCombinationPlan +{ + public string Runtime { get; set; } = string.Empty; + + public string Framework { get; set; } = string.Empty; + + public PowerForgeToolReleaseFlavor Flavor { get; set; } + + public string OutputPath { get; set; } = string.Empty; + + public string? ZipPath { get; set; } +} + +/// +/// Result of executing tool releases. +/// +internal sealed class PowerForgeToolReleaseResult +{ + public bool Success { get; set; } + + public string? ErrorMessage { get; set; } + + public PowerForgeToolReleaseArtifactResult[] Artefacts { get; set; } = Array.Empty(); + + public string[] ManifestPaths { get; set; } = Array.Empty(); +} + +/// +/// One produced downloadable tool artefact. +/// +internal sealed class PowerForgeToolReleaseArtifactResult +{ + public string Target { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string OutputName { get; set; } = string.Empty; + + public string Runtime { get; set; } = string.Empty; + + public string Framework { get; set; } = string.Empty; + + public PowerForgeToolReleaseFlavor Flavor { get; set; } + + public string OutputPath { get; set; } = string.Empty; + + public string ExecutablePath { get; set; } = string.Empty; + + public string? CommandAliasPath { get; set; } + + public string? ZipPath { get; set; } + + public int Files { get; set; } + + public long TotalBytes { get; set; } +} + +/// +/// Per-target manifest written after tool builds complete. +/// +internal sealed class PowerForgeToolReleaseManifest +{ + public string Target { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string OutputName { get; set; } = string.Empty; + + public PowerForgeToolReleaseArtifactResult[] Artefacts { get; set; } = Array.Empty(); +} diff --git a/PowerForge/Models/ProjectBuildHostExecutionResult.cs b/PowerForge/Models/ProjectBuildHostExecutionResult.cs new file mode 100644 index 00000000..0fc08128 --- /dev/null +++ b/PowerForge/Models/ProjectBuildHostExecutionResult.cs @@ -0,0 +1,57 @@ +namespace PowerForge; + +/// +/// Result returned by the host-facing project build service. +/// +public sealed class ProjectBuildHostExecutionResult +{ + /// + /// True when the requested operation completed successfully. + /// + public bool Success { get; set; } + + /// + /// Optional error message when is false. + /// + public string? ErrorMessage { get; set; } + + /// + /// Full path to the configuration file used for this execution. + /// + public string ConfigPath { get; set; } = string.Empty; + + /// + /// Resolved project/repository root used by the release workflow. + /// + public string RootPath { get; set; } = string.Empty; + + /// + /// Resolved staging path, when configured. + /// + public string? StagingPath { get; set; } + + /// + /// Resolved package output path, when configured. + /// + public string? OutputPath { get; set; } + + /// + /// Resolved release-zip output path, when configured. + /// + public string? ReleaseZipOutputPath { get; set; } + + /// + /// Resolved plan output path, when configured. + /// + public string? PlanOutputPath { get; set; } + + /// + /// Duration of the host operation. + /// + public TimeSpan Duration { get; set; } + + /// + /// Underlying project build result. + /// + public ProjectBuildResult Result { get; set; } = new(); +} diff --git a/PowerForge/Models/ProjectBuildHostRequest.cs b/PowerForge/Models/ProjectBuildHostRequest.cs new file mode 100644 index 00000000..baafd518 --- /dev/null +++ b/PowerForge/Models/ProjectBuildHostRequest.cs @@ -0,0 +1,48 @@ +namespace PowerForge; + +/// +/// Host-facing request for executing or planning a project build using a project.build.json file. +/// +public sealed class ProjectBuildHostRequest +{ + /// + /// Path to the project.build.json configuration file. + /// + public string ConfigPath { get; set; } = string.Empty; + + /// + /// Optional path where the generated plan JSON should be written. + /// + public string? PlanOutputPath { get; set; } + + /// + /// When true, executes the real build path after the planning pass. + /// When false, only the what-if/plan pass runs. + /// + public bool ExecuteBuild { get; set; } + + /// + /// Optional override for plan-only mode. + /// + public bool? PlanOnly { get; set; } + + /// + /// Optional override for version updates. + /// + public bool? UpdateVersions { get; set; } + + /// + /// Optional override for building/packing projects. + /// + public bool? Build { get; set; } + + /// + /// Optional override for NuGet publishing. + /// + public bool? PublishNuget { get; set; } + + /// + /// Optional override for GitHub release publishing. + /// + public bool? PublishGitHub { get; set; } +} diff --git a/PowerForge/Services/PowerForgeReleaseService.cs b/PowerForge/Services/PowerForgeReleaseService.cs new file mode 100644 index 00000000..869f179c --- /dev/null +++ b/PowerForge/Services/PowerForgeReleaseService.cs @@ -0,0 +1,286 @@ +namespace PowerForge; + +/// +/// Orchestrates package and tool release workflows from one unified configuration. +/// +internal sealed class PowerForgeReleaseService +{ + private readonly ILogger _logger; + private readonly Func _executePackages; + private readonly Func _planTools; + private readonly Func _runTools; + private readonly Func _publishGitHubRelease; + + /// + /// Creates a new unified release service. + /// + public PowerForgeReleaseService(ILogger logger) + : this( + logger, + (request, config, configPath) => new ProjectBuildHostService(logger).Execute(request, config, configPath), + (spec, configPath, request) => new PowerForgeToolReleaseService(logger).Plan(spec, configPath, request), + plan => new PowerForgeToolReleaseService(logger).Run(plan), + publishRequest => new GitHubReleasePublisher(logger).PublishRelease(publishRequest)) + { + } + + internal PowerForgeReleaseService( + ILogger logger, + Func executePackages, + Func planTools, + Func runTools, + Func publishGitHubRelease) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _executePackages = executePackages ?? throw new ArgumentNullException(nameof(executePackages)); + _planTools = planTools ?? throw new ArgumentNullException(nameof(planTools)); + _runTools = runTools ?? throw new ArgumentNullException(nameof(runTools)); + _publishGitHubRelease = publishGitHubRelease ?? throw new ArgumentNullException(nameof(publishGitHubRelease)); + } + + /// + /// Executes the unified release workflow. + /// + public PowerForgeReleaseResult Execute(PowerForgeReleaseSpec spec, PowerForgeReleaseRequest request) + { + if (spec is null) + throw new ArgumentNullException(nameof(spec)); + if (request is null) + throw new ArgumentNullException(nameof(request)); + + if (string.IsNullOrWhiteSpace(request.ConfigPath)) + throw new ArgumentException("ConfigPath is required.", nameof(request)); + + var configPath = Path.GetFullPath(request.ConfigPath.Trim().Trim('"')); + var configDirectory = Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory(); + var runPackages = !request.ToolsOnly && spec.Packages is not null; + var runTools = !request.PackagesOnly && spec.Tools is not null; + + if (!runPackages && !runTools) + { + return new PowerForgeReleaseResult + { + Success = false, + ConfigPath = configPath, + ErrorMessage = "Release config does not enable any selected Packages or Tools sections." + }; + } + + var result = new PowerForgeReleaseResult + { + Success = true, + ConfigPath = configPath + }; + + if (runPackages) + { + var packageRequest = new ProjectBuildHostRequest + { + ConfigPath = configPath, + ExecuteBuild = !request.PlanOnly && !request.ValidateOnly, + PlanOnly = request.PlanOnly || request.ValidateOnly ? true : null, + PublishNuget = request.PublishNuget, + PublishGitHub = request.PublishProjectGitHub + }; + + var packages = _executePackages(packageRequest, spec.Packages!, configPath); + result.Packages = packages; + if (!packages.Success) + { + result.Success = false; + result.ErrorMessage = packages.ErrorMessage ?? "Package release workflow failed."; + return result; + } + } + + if (runTools) + { + var toolPlan = _planTools(spec.Tools!, configPath, request); + result.ToolPlan = toolPlan; + + if (!request.PlanOnly && !request.ValidateOnly) + { + var tools = _runTools(toolPlan); + result.Tools = tools; + if (!tools.Success) + { + result.Success = false; + result.ErrorMessage = tools.ErrorMessage ?? "Tool release workflow failed."; + return result; + } + + var publishToolGitHub = request.PublishToolGitHub ?? spec.Tools!.GitHub.Publish; + if (publishToolGitHub) + { + var releases = PublishToolGitHubReleases(spec, configDirectory, toolPlan, tools); + result.ToolGitHubReleases = releases; + var failures = releases.Where(entry => !entry.Success).ToArray(); + if (failures.Length > 0) + { + result.Success = false; + result.ErrorMessage = failures[0].ErrorMessage ?? "Tool GitHub release publishing failed."; + return result; + } + } + } + } + + return result; + } + + private PowerForgeToolGitHubReleaseResult[] PublishToolGitHubReleases( + PowerForgeReleaseSpec spec, + string configDirectory, + PowerForgeToolReleasePlan plan, + PowerForgeToolReleaseResult result) + { + var gitHub = spec.Tools?.GitHub ?? new PowerForgeToolReleaseGitHubOptions(); + var owner = string.IsNullOrWhiteSpace(gitHub.Owner) + ? spec.Packages?.GitHubUsername + : gitHub.Owner!.Trim(); + var repository = string.IsNullOrWhiteSpace(gitHub.Repository) + ? spec.Packages?.GitHubRepositoryName + : gitHub.Repository!.Trim(); + var token = ProjectBuildSupportService.ResolveSecret( + gitHub.Token, + gitHub.TokenFilePath, + gitHub.TokenEnvName, + configDirectory); + + if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repository)) + { + return new[] + { + new PowerForgeToolGitHubReleaseResult + { + Success = false, + ErrorMessage = "Tool GitHub publishing requires Owner and Repository." + } + }; + } + + if (string.IsNullOrWhiteSpace(token)) + { + return new[] + { + new PowerForgeToolGitHubReleaseResult + { + Success = false, + ErrorMessage = "Tool GitHub publishing requires a token." + } + }; + } + + var tagTemplate = string.IsNullOrWhiteSpace(gitHub.TagTemplate) + ? "{Target}-v{Version}" + : gitHub.TagTemplate!; + var releaseNameTemplate = string.IsNullOrWhiteSpace(gitHub.ReleaseNameTemplate) + ? "{Target} {Version}" + : gitHub.ReleaseNameTemplate!; + + var artefactGroups = result.Artefacts + .Where(entry => !string.IsNullOrWhiteSpace(entry.ZipPath)) + .GroupBy(entry => (Target: entry.Target, Version: entry.Version)) + .ToArray(); + + if (artefactGroups.Length == 0) + { + return new[] + { + new PowerForgeToolGitHubReleaseResult + { + Success = false, + ErrorMessage = "Tool GitHub publishing requires zip assets, but no ZipPath values were produced." + } + }; + } + + var results = new List(); + foreach (var group in artefactGroups) + { + var assets = group + .Select(entry => entry.ZipPath!) + .Where(File.Exists) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (assets.Length == 0) + { + results.Add(new PowerForgeToolGitHubReleaseResult + { + Target = group.Key.Target, + Version = group.Key.Version, + Success = false, + ErrorMessage = $"No zip assets found on disk for tool target '{group.Key.Target}'." + }); + continue; + } + + var tagName = ApplyGitHubTemplate(tagTemplate, group.Key.Target, group.Key.Version, repository!); + var releaseName = ApplyGitHubTemplate(releaseNameTemplate, group.Key.Target, group.Key.Version, repository!); + + try + { + var publishResult = _publishGitHubRelease(new GitHubReleasePublishRequest + { + Owner = owner!, + Repository = repository!, + Token = token!, + TagName = tagName, + ReleaseName = releaseName, + GenerateReleaseNotes = gitHub.GenerateReleaseNotes, + IsPreRelease = gitHub.IsPreRelease, + ReuseExistingReleaseOnConflict = true, + AssetFilePaths = assets + }); + + results.Add(new PowerForgeToolGitHubReleaseResult + { + Target = group.Key.Target, + Version = group.Key.Version, + TagName = tagName, + ReleaseName = releaseName, + AssetPaths = assets, + Success = publishResult.Succeeded, + ReleaseUrl = publishResult.HtmlUrl, + ReusedExistingRelease = publishResult.ReusedExistingRelease, + ErrorMessage = publishResult.Succeeded ? null : "GitHub release publish failed.", + SkippedExistingAssets = publishResult.SkippedExistingAssets?.ToArray() ?? Array.Empty() + }); + } + catch (Exception ex) + { + results.Add(new PowerForgeToolGitHubReleaseResult + { + Target = group.Key.Target, + Version = group.Key.Version, + TagName = tagName, + ReleaseName = releaseName, + AssetPaths = assets, + Success = false, + ErrorMessage = ex.Message + }); + } + } + + return results.ToArray(); + } + + private static string ApplyGitHubTemplate(string template, string target, string version, string repository) + { + var now = DateTime.Now; + var utcNow = DateTime.UtcNow; + return template + .Replace("{Target}", target) + .Replace("{Project}", target) + .Replace("{Version}", version) + .Replace("{Repo}", repository) + .Replace("{Repository}", repository) + .Replace("{Date}", now.ToString("yyyy.MM.dd")) + .Replace("{UtcDate}", utcNow.ToString("yyyy.MM.dd")) + .Replace("{DateTime}", now.ToString("yyyyMMddHHmmss")) + .Replace("{UtcDateTime}", utcNow.ToString("yyyyMMddHHmmss")) + .Replace("{Timestamp}", now.ToString("yyyyMMddHHmmss")) + .Replace("{UtcTimestamp}", utcNow.ToString("yyyyMMddHHmmss")); + } +} diff --git a/PowerForge/Services/PowerForgeToolReleaseService.cs b/PowerForge/Services/PowerForgeToolReleaseService.cs new file mode 100644 index 00000000..b0eb2692 --- /dev/null +++ b/PowerForge/Services/PowerForgeToolReleaseService.cs @@ -0,0 +1,650 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Text; +using System.Text.Json; + +namespace PowerForge; + +/// +/// Builds downloadable runtime-specific tool executables from a typed configuration. +/// +internal sealed class PowerForgeToolReleaseService +{ + private readonly ILogger _logger; + private readonly Func _runProcess; + + /// + /// Creates a new tool release service. + /// + public PowerForgeToolReleaseService(ILogger logger) + : this(logger, RunProcess) + { + } + + internal PowerForgeToolReleaseService( + ILogger logger, + Func runProcess) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _runProcess = runProcess ?? throw new ArgumentNullException(nameof(runProcess)); + } + + /// + /// Plans tool outputs without executing publish commands. + /// + public PowerForgeToolReleasePlan Plan(PowerForgeToolReleaseSpec spec, string? configPath, PowerForgeReleaseRequest? request = null) + { + if (spec is null) + throw new ArgumentNullException(nameof(spec)); + + var configDir = string.IsNullOrWhiteSpace(configPath) + ? Directory.GetCurrentDirectory() + : Path.GetDirectoryName(Path.GetFullPath(configPath)) ?? Directory.GetCurrentDirectory(); + + var projectRoot = string.IsNullOrWhiteSpace(spec.ProjectRoot) + ? configDir + : ResolvePath(configDir, spec.ProjectRoot!); + + if (!Directory.Exists(projectRoot)) + throw new DirectoryNotFoundException($"Tool release ProjectRoot not found: {projectRoot}"); + + var configuration = string.IsNullOrWhiteSpace(spec.Configuration) ? "Release" : spec.Configuration.Trim(); + var selectedTargets = NormalizeStrings(request?.Targets); + var overrideRuntimes = NormalizeStrings(request?.Runtimes); + var overrideFrameworks = NormalizeStrings(request?.Frameworks); + var overrideFlavors = NormalizeFlavors(request?.Flavors); + + var plans = new List(); + foreach (var target in spec.Targets ?? Array.Empty()) + { + if (target is null) + continue; + + var name = (target.Name ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Tools.Targets[].Name is required.", nameof(spec)); + + if (selectedTargets.Length > 0 && !selectedTargets.Contains(name, StringComparer.OrdinalIgnoreCase)) + continue; + + var projectPath = ResolvePath(projectRoot, target.ProjectPath ?? string.Empty); + if (string.IsNullOrWhiteSpace(target.ProjectPath)) + throw new ArgumentException($"Tools target '{name}' requires ProjectPath.", nameof(spec)); + if (!File.Exists(projectPath)) + throw new FileNotFoundException($"Tool release project not found for target '{name}': {projectPath}", projectPath); + + if (!CsprojVersionEditor.TryGetVersion(projectPath, out var version) || string.IsNullOrWhiteSpace(version)) + throw new InvalidOperationException($"Unable to resolve Version/VersionPrefix from '{projectPath}'."); + + var outputName = string.IsNullOrWhiteSpace(target.OutputName) ? name : target.OutputName.Trim(); + var commandAlias = string.IsNullOrWhiteSpace(target.CommandAlias) ? null : target.CommandAlias!.Trim(); + + var frameworks = overrideFrameworks.Length > 0 + ? overrideFrameworks + : NormalizeStrings(target.Frameworks); + if (frameworks.Length == 0) + throw new ArgumentException($"Tools target '{name}' requires at least one framework.", nameof(spec)); + + var runtimes = overrideRuntimes.Length > 0 + ? overrideRuntimes + : NormalizeStrings(target.Runtimes); + if (runtimes.Length == 0) + throw new ArgumentException($"Tools target '{name}' requires at least one runtime.", nameof(spec)); + + var flavors = overrideFlavors.Length > 0 + ? overrideFlavors + : NormalizeFlavors(target.Flavors); + if (flavors.Length == 0) + flavors = new[] { target.Flavor }; + + var artifactRoot = string.IsNullOrWhiteSpace(target.ArtifactRootPath) + ? ResolvePath(projectRoot, Path.Combine("Artifacts", outputName)) + : ResolvePath(projectRoot, target.ArtifactRootPath!); + + var msbuildProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in target.MsBuildProperties ?? new Dictionary()) + { + if (string.IsNullOrWhiteSpace(kv.Key)) + continue; + + msbuildProperties[kv.Key.Trim()] = kv.Value ?? string.Empty; + } + + var combinations = new List(); + foreach (var framework in frameworks) + { + foreach (var runtime in runtimes) + { + foreach (var flavor in flavors) + { + var tokens = BuildTokens(name, outputName, version, runtime, framework, flavor, configuration); + var defaultOutput = Path.Combine(artifactRoot, "{rid}", "{framework}", "{flavor}"); + var outputTemplate = string.IsNullOrWhiteSpace(target.OutputPath) + ? defaultOutput + : target.OutputPath!; + + var outputPath = ResolvePath(projectRoot, ApplyTemplate(outputTemplate, tokens)); + var zipPath = ResolveZipPath(projectRoot, target, outputPath, tokens); + + combinations.Add(new PowerForgeToolReleaseCombinationPlan + { + Runtime = runtime, + Framework = framework, + Flavor = flavor, + OutputPath = outputPath, + ZipPath = target.Zip ? zipPath : null + }); + } + } + } + + plans.Add(new PowerForgeToolReleaseTargetPlan + { + Name = name, + ProjectPath = projectPath, + OutputName = outputName, + CommandAlias = commandAlias, + Version = version, + ArtifactRootPath = artifactRoot, + UseStaging = target.UseStaging, + ClearOutput = target.ClearOutput, + Zip = target.Zip, + KeepSymbols = target.KeepSymbols, + KeepDocs = target.KeepDocs, + CreateCommandAliasOnUnix = target.CreateCommandAliasOnUnix, + MsBuildProperties = msbuildProperties, + Combinations = combinations + .OrderBy(c => c.Framework, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.Runtime, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.Flavor.ToString(), StringComparer.OrdinalIgnoreCase) + .ToArray() + }); + } + + if (selectedTargets.Length > 0) + { + var missing = selectedTargets + .Where(selected => plans.All(plan => !string.Equals(plan.Name, selected, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + if (missing.Length > 0) + throw new ArgumentException($"Unknown tool target(s): {string.Join(", ", missing)}", nameof(request)); + } + + if (plans.Count == 0) + throw new InvalidOperationException("No tool release targets were selected."); + + return new PowerForgeToolReleasePlan + { + ProjectRoot = projectRoot, + Configuration = configuration, + Targets = plans.ToArray() + }; + } + + /// + /// Executes the planned tool releases. + /// + public PowerForgeToolReleaseResult Run(PowerForgeToolReleasePlan plan) + { + if (plan is null) + throw new ArgumentNullException(nameof(plan)); + + var artefacts = new List(); + var manifests = new List(); + + try + { + foreach (var target in plan.Targets ?? Array.Empty()) + { + var targetArtefacts = new List(); + foreach (var combination in target.Combinations ?? Array.Empty()) + { + targetArtefacts.Add(PublishOne(plan, target, combination)); + } + + artefacts.AddRange(targetArtefacts); + manifests.Add(WriteManifest(target, targetArtefacts)); + } + + return new PowerForgeToolReleaseResult + { + Success = true, + Artefacts = artefacts.ToArray(), + ManifestPaths = manifests.ToArray() + }; + } + catch (Exception ex) + { + _logger.Error(ex.Message); + if (_logger.IsVerbose) + _logger.Verbose(ex.ToString()); + + return new PowerForgeToolReleaseResult + { + Success = false, + ErrorMessage = ex.Message, + Artefacts = artefacts.ToArray(), + ManifestPaths = manifests.ToArray() + }; + } + } + + private PowerForgeToolReleaseArtifactResult PublishOne( + PowerForgeToolReleasePlan plan, + PowerForgeToolReleaseTargetPlan target, + PowerForgeToolReleaseCombinationPlan combination) + { + Directory.CreateDirectory(target.ArtifactRootPath); + + var publishDir = combination.OutputPath; + string? stagingDir = null; + if (target.UseStaging) + { + stagingDir = Path.Combine(Path.GetTempPath(), $"PowerForge.ToolRelease.{Guid.NewGuid():N}"); + publishDir = stagingDir; + Directory.CreateDirectory(publishDir); + } + + try + { + if (target.ClearOutput && !target.UseStaging) + ClearDirectory(combination.OutputPath); + + Directory.CreateDirectory(publishDir); + ExecutePublish(plan, target, combination, publishDir); + ApplyCleanup(publishDir, target); + var executablePath = RenameMainExecutable(target, publishDir, combination.Runtime); + + if (target.ClearOutput && target.UseStaging) + ClearDirectory(combination.OutputPath); + + if (target.UseStaging) + CopyDirectoryContents(publishDir, combination.OutputPath); + + var finalExecutablePath = target.UseStaging + ? Path.Combine(combination.OutputPath, Path.GetFileName(executablePath)) + : executablePath; + + string? aliasPath = null; + if (!combination.Runtime.StartsWith("win-", StringComparison.OrdinalIgnoreCase) + && target.CreateCommandAliasOnUnix + && !string.IsNullOrWhiteSpace(target.CommandAlias)) + { + aliasPath = Path.Combine(combination.OutputPath, target.CommandAlias!); + if (!string.Equals(aliasPath, finalExecutablePath, StringComparison.OrdinalIgnoreCase)) + File.Copy(finalExecutablePath, aliasPath, overwrite: true); + } + + string? zipPath = null; + if (!string.IsNullOrWhiteSpace(combination.ZipPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(combination.ZipPath!)!); + if (File.Exists(combination.ZipPath!)) + File.Delete(combination.ZipPath!); + ZipFile.CreateFromDirectory(combination.OutputPath, combination.ZipPath!); + zipPath = combination.ZipPath; + } + + var (files, totalBytes) = SummarizeDirectory(combination.OutputPath); + return new PowerForgeToolReleaseArtifactResult + { + Target = target.Name, + Version = target.Version, + OutputName = target.OutputName, + Runtime = combination.Runtime, + Framework = combination.Framework, + Flavor = combination.Flavor, + OutputPath = combination.OutputPath, + ExecutablePath = finalExecutablePath, + CommandAliasPath = aliasPath, + ZipPath = zipPath, + Files = files, + TotalBytes = totalBytes + }; + } + finally + { + if (!string.IsNullOrWhiteSpace(stagingDir) && Directory.Exists(stagingDir)) + { + try + { + Directory.Delete(stagingDir, recursive: true); + } + catch + { + // best effort + } + } + } + } + + private void ExecutePublish( + PowerForgeToolReleasePlan plan, + PowerForgeToolReleaseTargetPlan target, + PowerForgeToolReleaseCombinationPlan combination, + string publishDir) + { + var projectName = Path.GetFileNameWithoutExtension(target.ProjectPath) ?? target.Name; + _logger.Info($"Publishing {target.Name} {target.Version} ({combination.Framework}, {combination.Runtime}, {combination.Flavor})"); + + var psi = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = Path.GetDirectoryName(target.ProjectPath) ?? plan.ProjectRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + ProcessStartInfoEncoding.TryApplyUtf8(psi); + + var args = new List + { + "publish", + Quote(target.ProjectPath), + "-c", + Quote(plan.Configuration), + "-f", + Quote(combination.Framework), + "-r", + Quote(combination.Runtime) + }; + + var (selfContained, singleFile, compress, selfExtract) = ResolveFlavor(combination.Flavor); + args.Add($"--self-contained:{selfContained.ToString().ToLowerInvariant()}"); + args.Add($"/p:PublishSingleFile={singleFile.ToString().ToLowerInvariant()}"); + args.Add("/p:PublishReadyToRun=false"); + args.Add("/p:PublishTrimmed=false"); + args.Add($"/p:IncludeAllContentForSelfExtract={selfExtract.ToString().ToLowerInvariant()}"); + args.Add($"/p:IncludeNativeLibrariesForSelfExtract={selfExtract.ToString().ToLowerInvariant()}"); + args.Add($"/p:EnableCompressionInSingleFile={compress.ToString().ToLowerInvariant()}"); + args.Add("/p:EnableSingleFileAnalyzer=false"); + args.Add("/p:DebugType=None"); + args.Add("/p:DebugSymbols=false"); + args.Add("/p:GenerateDocumentationFile=false"); + args.Add("/p:CopyDocumentationFiles=false"); + args.Add("/p:ExcludeSymbolsFromSingleFile=true"); + args.Add("/p:ErrorOnDuplicatePublishOutputFiles=false"); + args.Add("/p:UseAppHost=true"); + args.Add($"/p:PublishDir={Quote(publishDir)}"); + + foreach (var kv in target.MsBuildProperties) + args.Add($"/p:{kv.Key}={kv.Value}"); + + psi.Arguments = string.Join(" ", args); + + var processResult = _runProcess(psi); + if (processResult.ExitCode != 0) + { + throw new InvalidOperationException( + $"dotnet publish failed for '{target.Name}' ({projectName}, {combination.Runtime}, {combination.Framework}, {combination.Flavor}). " + + $"{TrimForMessage(processResult.StdErr, processResult.StdOut)}"); + } + } + + private void ApplyCleanup(string publishDir, PowerForgeToolReleaseTargetPlan target) + { + if (!target.KeepSymbols) + { + foreach (var file in Directory.EnumerateFiles(publishDir, "*.pdb", SearchOption.AllDirectories)) + { + try { File.Delete(file); } catch { } + } + } + + if (!target.KeepDocs) + { + foreach (var file in Directory.EnumerateFiles(publishDir, "*", SearchOption.AllDirectories)) + { + var extension = Path.GetExtension(file); + if (!extension.Equals(".xml", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase)) + continue; + + try { File.Delete(file); } catch { } + } + } + } + + private static string RenameMainExecutable( + PowerForgeToolReleaseTargetPlan target, + string publishDir, + string runtime) + { + var isWindows = runtime.StartsWith("win-", StringComparison.OrdinalIgnoreCase); + var candidateName = Path.GetFileNameWithoutExtension(target.ProjectPath) ?? target.Name; + var sourceName = isWindows ? $"{candidateName}.exe" : candidateName; + var sourcePath = Path.Combine(publishDir, sourceName); + if (!File.Exists(sourcePath)) + { + sourcePath = FindLargestCandidate(publishDir, isWindows) + ?? throw new FileNotFoundException($"Main executable not found in publish output: {publishDir}"); + } + + var desiredName = isWindows ? $"{target.OutputName}.exe" : target.OutputName; + var desiredPath = Path.Combine(publishDir, desiredName); + if (!string.Equals(sourcePath, desiredPath, StringComparison.OrdinalIgnoreCase)) + { + if (File.Exists(desiredPath)) + File.Delete(desiredPath); + File.Move(sourcePath, desiredPath); + } + + return desiredPath; + } + + private static string? FindLargestCandidate(string publishDir, bool isWindows) + { + var files = Directory.EnumerateFiles(publishDir, "*", SearchOption.TopDirectoryOnly) + .Select(path => new FileInfo(path)) + .Where(file => isWindows + ? string.Equals(file.Extension, ".exe", StringComparison.OrdinalIgnoreCase) + : string.IsNullOrWhiteSpace(file.Extension)) + .OrderByDescending(file => file.Length) + .ToArray(); + + return files.FirstOrDefault()?.FullName; + } + + private static (bool SelfContained, bool SingleFile, bool Compress, bool SelfExtract) ResolveFlavor(PowerForgeToolReleaseFlavor flavor) + => flavor switch + { + PowerForgeToolReleaseFlavor.SingleContained => (true, true, true, true), + PowerForgeToolReleaseFlavor.SingleFx => (false, true, true, false), + PowerForgeToolReleaseFlavor.Portable => (true, false, false, false), + PowerForgeToolReleaseFlavor.Fx => (false, false, false, false), + _ => throw new ArgumentOutOfRangeException(nameof(flavor), flavor, "Unsupported tool release flavor.") + }; + + private static string WriteManifest(PowerForgeToolReleaseTargetPlan target, IReadOnlyList artefacts) + { + var manifestPath = Path.Combine(target.ArtifactRootPath, "release-manifest.json"); + Directory.CreateDirectory(target.ArtifactRootPath); + + var manifest = new PowerForgeToolReleaseManifest + { + Target = target.Name, + Version = target.Version, + OutputName = target.OutputName, + Artefacts = artefacts.ToArray() + }; + + var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine; + File.WriteAllText(manifestPath, json, new UTF8Encoding(false)); + return manifestPath; + } + + private static string ResolveZipPath( + string projectRoot, + PowerForgeToolReleaseTarget target, + string outputPath, + IReadOnlyDictionary tokens) + { + if (!target.Zip) + return string.Empty; + + if (!string.IsNullOrWhiteSpace(target.ZipPath)) + return ResolvePath(projectRoot, ApplyTemplate(target.ZipPath!, tokens)); + + var zipNameTemplate = string.IsNullOrWhiteSpace(target.ZipNameTemplate) + ? "{outputName}-{version}-{framework}-{rid}-{flavor}.zip" + : target.ZipNameTemplate!; + var zipName = ApplyTemplate(zipNameTemplate, tokens); + if (!zipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + zipName += ".zip"; + + return Path.Combine(Path.GetDirectoryName(outputPath)!, zipName); + } + + private static Dictionary BuildTokens( + string target, + string outputName, + string version, + string runtime, + string framework, + PowerForgeToolReleaseFlavor flavor, + string configuration) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["target"] = target, + ["outputName"] = outputName, + ["version"] = version, + ["rid"] = runtime, + ["runtime"] = runtime, + ["framework"] = framework, + ["flavor"] = flavor.ToString(), + ["configuration"] = configuration + }; + } + + private static string ApplyTemplate(string template, IReadOnlyDictionary tokens) + { + var value = template ?? string.Empty; + foreach (var kv in tokens) + value = value.Replace("{" + kv.Key + "}", kv.Value ?? string.Empty); + return value; + } + + private static string[] NormalizeStrings(IEnumerable? values) + => (values ?? Array.Empty()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static PowerForgeToolReleaseFlavor[] NormalizeFlavors(IEnumerable? values) + => (values ?? Array.Empty()) + .Distinct() + .ToArray(); + + private static string ResolvePath(string basePath, string value) + { + var trimmed = (value ?? string.Empty).Trim().Trim('"'); + if (string.IsNullOrWhiteSpace(trimmed)) + throw new ArgumentException("Path value is required.", nameof(value)); + + return Path.GetFullPath(Path.IsPathRooted(trimmed) ? trimmed : Path.Combine(basePath, trimmed)); + } + + private static void ClearDirectory(string path) + { + if (!Directory.Exists(path)) + return; + + foreach (var entry in Directory.GetFileSystemEntries(path)) + { + try + { + if (Directory.Exists(entry)) + Directory.Delete(entry, recursive: true); + else + File.Delete(entry); + } + catch + { + // best effort + } + } + } + + private static void CopyDirectoryContents(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (var directory in Directory.GetDirectories(source, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, directory); + Directory.CreateDirectory(Path.Combine(destination, relative)); + } + + foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, file); + var targetPath = Path.Combine(destination, relative); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Copy(file, targetPath, overwrite: true); + } + } + + private static (int Files, long TotalBytes) SummarizeDirectory(string path) + { + var files = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Select(file => new FileInfo(file)) + .ToArray(); + + long total = 0; + foreach (var file in files) + total += file.Length; + + return (files.Length, total); + } + + private static ProcessExecutionResult RunProcess(ProcessStartInfo startInfo) + { + using var process = Process.Start(startInfo); + if (process is null) + return new ProcessExecutionResult(1, string.Empty, "Failed to start process."); + + var stdOut = process.StandardOutput.ReadToEnd(); + var stdErr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return new ProcessExecutionResult(process.ExitCode, stdOut, stdErr); + } + + private static string TrimForMessage(string? stdErr, string? stdOut) + { + var combined = string.Join( + Environment.NewLine, + new[] { stdErr?.Trim(), stdOut?.Trim() }.Where(text => !string.IsNullOrWhiteSpace(text))); + if (combined.Length <= 3000) + return combined; + + return combined.Substring(0, 3000) + "..."; + } + + private static string Quote(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "\"\""; + + return value.Contains(" ", StringComparison.Ordinal) || value.Contains("\"", StringComparison.Ordinal) + ? "\"" + value.Replace("\"", "\\\"") + "\"" + : value; + } + + internal struct ProcessExecutionResult + { + public ProcessExecutionResult(int exitCode, string stdOut, string stdErr) + { + ExitCode = exitCode; + StdOut = stdOut ?? string.Empty; + StdErr = stdErr ?? string.Empty; + } + + public int ExitCode { get; } + + public string StdOut { get; } + + public string StdErr { get; } + } +} diff --git a/PowerForge/Services/ProjectBuildHostService.cs b/PowerForge/Services/ProjectBuildHostService.cs new file mode 100644 index 00000000..c62d6113 --- /dev/null +++ b/PowerForge/Services/ProjectBuildHostService.cs @@ -0,0 +1,128 @@ +namespace PowerForge; + +/// +/// Host-facing service for planning or executing repository project builds from project.build.json. +/// +public sealed class ProjectBuildHostService +{ + private readonly ILogger _logger; + private readonly Func? _executeRelease; + private readonly Func? _publishGitHub; + private readonly Func? _validateGitHubPreflight; + + /// + /// Creates a new host service using a null logger. + /// + public ProjectBuildHostService() + : this(new NullLogger()) + { + } + + /// + /// Creates a new host service using the provided logger. + /// + /// Logger used by the underlying workflow. + public ProjectBuildHostService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + internal ProjectBuildHostService( + ILogger logger, + Func? executeRelease, + Func? publishGitHub, + Func? validateGitHubPreflight) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _executeRelease = executeRelease; + _publishGitHub = publishGitHub; + _validateGitHubPreflight = validateGitHubPreflight; + } + + /// + /// Executes the requested project build workflow. + /// + /// Host execution request. + /// Execution result including resolved paths and the underlying workflow result. + public ProjectBuildHostExecutionResult Execute(ProjectBuildHostRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.ConfigPath)) + throw new ArgumentException("ConfigPath is required.", nameof(request)); + + var startedAt = DateTimeOffset.UtcNow; + var configPath = Path.GetFullPath(request.ConfigPath.Trim().Trim('"')); + var configDirectory = Path.GetDirectoryName(configPath); + if (string.IsNullOrWhiteSpace(configDirectory)) + throw new InvalidOperationException($"Unable to resolve the configuration directory for '{configPath}'."); + + var support = new ProjectBuildSupportService(_logger); + var config = support.LoadConfig(configPath); + return ExecuteCore(request, config, configPath, configDirectory, startedAt); + } + + /// + /// Executes the requested project build workflow using an already loaded configuration object. + /// + /// Host execution request. + /// Loaded configuration to execute. + /// Source configuration path used for resolving relative values and reporting. + internal ProjectBuildHostExecutionResult Execute(ProjectBuildHostRequest request, ProjectBuildConfiguration config, string configPath) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (config is null) + throw new ArgumentNullException(nameof(config)); + if (string.IsNullOrWhiteSpace(configPath)) + throw new ArgumentException("Config path is required.", nameof(configPath)); + + var startedAt = DateTimeOffset.UtcNow; + var fullConfigPath = Path.GetFullPath(configPath.Trim().Trim('"')); + var configDirectory = Path.GetDirectoryName(fullConfigPath); + if (string.IsNullOrWhiteSpace(configDirectory)) + throw new InvalidOperationException($"Unable to resolve the configuration directory for '{fullConfigPath}'."); + + return ExecuteCore(request, config, fullConfigPath, configDirectory, startedAt); + } + + private ProjectBuildHostExecutionResult ExecuteCore( + ProjectBuildHostRequest request, + ProjectBuildConfiguration config, + string configPath, + string configDirectory, + DateTimeOffset startedAt) + { + var preparation = new ProjectBuildPreparationService().Prepare( + config, + configDirectory, + request.PlanOutputPath, + new ProjectBuildRequestedActions { + PlanOnly = request.PlanOnly, + UpdateVersions = request.UpdateVersions, + Build = request.Build, + PublishNuget = request.PublishNuget, + PublishGitHub = request.PublishGitHub + }); + + var workflow = new ProjectBuildWorkflowService( + _logger, + _executeRelease, + _publishGitHub, + _validateGitHubPreflight) + .Execute(config, configDirectory, preparation, request.ExecuteBuild); + + return new ProjectBuildHostExecutionResult { + Success = workflow.Result.Success, + ErrorMessage = workflow.Result.ErrorMessage, + ConfigPath = configPath, + RootPath = preparation.RootPath, + StagingPath = preparation.StagingPath, + OutputPath = preparation.OutputPath, + ReleaseZipOutputPath = preparation.ReleaseZipOutputPath, + PlanOutputPath = preparation.PlanOutputPath, + Duration = DateTimeOffset.UtcNow - startedAt, + Result = workflow.Result + }; + } +} diff --git a/README.MD b/README.MD index 707176a3..b61f1cfb 100644 --- a/README.MD +++ b/README.MD @@ -210,25 +210,28 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} ``` -For release/build packaging, this repo now also ships a standard project-build entrypoint: +For release/build packaging, this repo now uses one unified release entrypoint: ```powershell .\Build\Build-Project.ps1 .\Build\Build-Project.ps1 -Plan .\Build\Build-Project.ps1 -PublishNuget $true -PublishGitHub $true +.\Build\Build-Project.ps1 -ToolsOnly -PublishToolGitHub $true ``` -For downloadable CLI binaries, use the tool release builders: +`Build\release.json` is the source of truth for both package releases and downloadable tool binaries. + +For targeted tool-only runs, the convenience wrappers still exist: ```powershell # Build PowerForge.exe / PowerForge for one runtime -.\Build\Build-PowerForge.ps1 -Tool PowerForge -Runtime win-x64 -Framework net8.0 -Flavor SingleFx -Zip +.\Build\Build-PowerForge.ps1 -Tool PowerForge -Runtime win-x64 -Framework net10.0 -Flavor SingleContained # Build PowerForgeWeb.exe / PowerForgeWeb -.\Build\Build-PowerForgeWeb.ps1 -Runtime win-x64 -Framework net8.0 -Flavor SingleFx -Zip +.\Build\Build-PowerForgeWeb.ps1 -Runtime win-x64 -Framework net10.0 -Flavor SingleContained # Optional: publish the generated zip assets to GitHub releases -.\Build\Build-PowerForge.ps1 -Tool All -Runtime win-x64,linux-x64,osx-arm64 -Framework net8.0 -Flavor SingleFx -Zip -PublishGitHub +.\Build\Build-PowerForge.ps1 -Tool All -Runtime win-x64,linux-x64,osx-arm64 -Framework net10.0 -Flavor SingleContained -PublishGitHub ``` Introduced in **1.0.0** a new way to build PowerShell module based on DSL language. diff --git a/Schemas/powerforge.release.schema.json b/Schemas/powerforge.release.schema.json new file mode 100644 index 00000000..fe9e1bd6 --- /dev/null +++ b/Schemas/powerforge.release.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PowerForge Unified Release Configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "SchemaVersion": { "type": "integer", "minimum": 1 }, + "Packages": { "$ref": "project.build.schema.json" }, + "Tools": { + "type": "object", + "additionalProperties": false, + "properties": { + "ProjectRoot": { "type": [ "string", "null" ] }, + "Configuration": { "type": "string", "enum": [ "Release", "Debug" ] }, + "Targets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ "Name", "ProjectPath", "OutputName", "Runtimes", "Frameworks" ], + "properties": { + "Name": { "type": "string" }, + "ProjectPath": { "type": "string" }, + "OutputName": { "type": "string" }, + "CommandAlias": { "type": [ "string", "null" ] }, + "Runtimes": { "type": "array", "items": { "type": "string" } }, + "Frameworks": { "type": "array", "items": { "type": "string" } }, + "Flavor": { "type": "string", "enum": [ "SingleContained", "SingleFx", "Portable", "Fx" ] }, + "Flavors": { + "type": "array", + "items": { "type": "string", "enum": [ "SingleContained", "SingleFx", "Portable", "Fx" ] } + }, + "ArtifactRootPath": { "type": [ "string", "null" ] }, + "OutputPath": { "type": [ "string", "null" ] }, + "UseStaging": { "type": "boolean" }, + "ClearOutput": { "type": "boolean" }, + "Zip": { "type": "boolean" }, + "ZipPath": { "type": [ "string", "null" ] }, + "ZipNameTemplate": { "type": [ "string", "null" ] }, + "KeepSymbols": { "type": "boolean" }, + "KeepDocs": { "type": "boolean" }, + "CreateCommandAliasOnUnix": { "type": "boolean" }, + "MsBuildProperties": { + "type": [ "object", "null" ], + "additionalProperties": { "type": "string" } + } + } + } + }, + "GitHub": { + "type": "object", + "additionalProperties": false, + "properties": { + "Publish": { "type": "boolean" }, + "Owner": { "type": [ "string", "null" ] }, + "Repository": { "type": [ "string", "null" ] }, + "Token": { "type": [ "string", "null" ] }, + "TokenFilePath": { "type": [ "string", "null" ] }, + "TokenEnvName": { "type": [ "string", "null" ] }, + "GenerateReleaseNotes": { "type": "boolean" }, + "IsPreRelease": { "type": "boolean" }, + "TagTemplate": { "type": [ "string", "null" ] }, + "ReleaseNameTemplate": { "type": [ "string", "null" ] } + } + } + } + } + } +} From bc89deddbda6b1732594fcb2266609872f7ba47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 13 Mar 2026 12:17:34 +0100 Subject: [PATCH 7/7] Address follow-up release review fixes --- Build/Build-Project.ps1 | 26 ++++++------ .../PowerForgeReleaseServiceTests.cs | 41 +++++++++++++++++++ .../Services/PowerForgeToolReleaseService.cs | 9 ++-- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/Build/Build-Project.ps1 b/Build/Build-Project.ps1 index 7fff9e69..e27cc927 100644 --- a/Build/Build-Project.ps1 +++ b/Build/Build-Project.ps1 @@ -21,21 +21,21 @@ if (-not $PSBoundParameters.ContainsKey('ConfigPath') -or [string]::IsNullOrWhit $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $project = Join-Path $repoRoot 'PowerForge.Cli\PowerForge.Cli.csproj' -$args = @( +$dotnetArgs = @( 'run', '--project', $project, '-c', 'Release', '--framework', 'net10.0', '--no-launch-profile', '--', 'release', '--config', $ConfigPath ) -if ($Plan) { $args += '--plan' } -if ($Validate) { $args += '--validate' } -if ($PackagesOnly) { $args += '--packages-only' } -if ($ToolsOnly) { $args += '--tools-only' } -if ($PublishNuget) { $args += '--publish-nuget' } -if ($PublishGitHub) { $args += '--publish-project-github' } -if ($PublishToolGitHub) { $args += '--publish-tool-github' } -foreach ($entry in $Target) { $args += @('--target', $entry) } -foreach ($entry in $Runtime) { $args += @('--rid', $entry) } -foreach ($entry in $Framework) { $args += @('--framework', $entry) } -foreach ($entry in $Flavor) { $args += @('--flavor', $entry) } +if ($Plan) { $dotnetArgs += '--plan' } +if ($Validate) { $dotnetArgs += '--validate' } +if ($PackagesOnly) { $dotnetArgs += '--packages-only' } +if ($ToolsOnly) { $dotnetArgs += '--tools-only' } +if ($PublishNuget) { $dotnetArgs += '--publish-nuget' } +if ($PublishGitHub) { $dotnetArgs += '--publish-project-github' } +if ($PublishToolGitHub) { $dotnetArgs += '--publish-tool-github' } +foreach ($entry in $Target) { $dotnetArgs += @('--target', $entry) } +foreach ($entry in $Runtime) { $dotnetArgs += @('--rid', $entry) } +foreach ($entry in $Framework) { $dotnetArgs += @('--framework', $entry) } +foreach ($entry in $Flavor) { $dotnetArgs += @('--flavor', $entry) } -dotnet @args +dotnet @dotnetArgs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs index 984ea5c7..b9a57d90 100644 --- a/PowerForge.Tests/PowerForgeReleaseServiceTests.cs +++ b/PowerForge.Tests/PowerForgeReleaseServiceTests.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Diagnostics; namespace PowerForge.Tests; @@ -181,6 +182,46 @@ public void Execute_GroupsToolAssetsIntoSingleGitHubReleasePerTarget() } } + [Fact] + public void ToolReleaseRunProcess_CapturesStdOutAndStdErrWithoutBlocking() + { + var method = typeof(PowerForgeToolReleaseService).GetMethod("RunProcess", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.NotNull(method); + + var tempScript = Path.Combine(Path.GetTempPath(), $"powerforge-toolrelease-{Guid.NewGuid():N}.cmd"); + try + { + File.WriteAllText(tempScript, "@echo stdout-line\r\n@echo stderr-line 1>&2\r\n", new UTF8Encoding(false)); + + var psi = new ProcessStartInfo + { + FileName = "cmd.exe", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("/c"); + psi.ArgumentList.Add(tempScript); + + var result = method!.Invoke(null, new object?[] { psi }); + Assert.NotNull(result); + + var exitCode = (int)result.GetType().GetProperty("ExitCode")!.GetValue(result)!; + var stdOut = (string)result.GetType().GetProperty("StdOut")!.GetValue(result)!; + var stdErr = (string)result.GetType().GetProperty("StdErr")!.GetValue(result)!; + + Assert.Equal(0, exitCode); + Assert.Contains("stdout-line", stdOut, StringComparison.OrdinalIgnoreCase); + Assert.Contains("stderr-line", stdErr, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (File.Exists(tempScript)) + File.Delete(tempScript); + } + } + private static string CreateSandbox() { var path = Path.Combine(Path.GetTempPath(), "PowerForge.ReleaseTests", Guid.NewGuid().ToString("N")); diff --git a/PowerForge/Services/PowerForgeToolReleaseService.cs b/PowerForge/Services/PowerForgeToolReleaseService.cs index b0eb2692..304bdf53 100644 --- a/PowerForge/Services/PowerForgeToolReleaseService.cs +++ b/PowerForge/Services/PowerForgeToolReleaseService.cs @@ -605,10 +605,13 @@ private static ProcessExecutionResult RunProcess(ProcessStartInfo startInfo) if (process is null) return new ProcessExecutionResult(1, string.Empty, "Failed to start process."); - var stdOut = process.StandardOutput.ReadToEnd(); - var stdErr = process.StandardError.ReadToEnd(); + var stdOutTask = process.StandardOutput.ReadToEndAsync(); + var stdErrTask = process.StandardError.ReadToEndAsync(); process.WaitForExit(); - return new ProcessExecutionResult(process.ExitCode, stdOut, stdErr); + return new ProcessExecutionResult( + process.ExitCode, + stdOutTask.GetAwaiter().GetResult(), + stdErrTask.GetAwaiter().GetResult()); } private static string TrimForMessage(string? stdErr, string? stdOut)