Skip to content

Add reusable GitHub housekeeping workflows#211

Merged
PrzemyslawKlys merged 8 commits intomainfrom
codex/github-housekeeping
Mar 13, 2026
Merged

Add reusable GitHub housekeeping workflows#211
PrzemyslawKlys merged 8 commits intomainfrom
codex/github-housekeeping

Conversation

@PrzemyslawKlys
Copy link
Member

Summary

  • add shared C# GitHub cache cleanup and runner housekeeping services
  • expose powerforge github caches prune and powerforge github runner cleanup
  • add a reusable composite action and a scheduled housekeeping workflow

Verification

  • dotnet build .\PowerForge\PowerForge.csproj -c Release
  • dotnet build .\PowerForge.Cli\PowerForge.Cli.csproj -c Release
  • dotnet test .\PowerForge.Tests\PowerForge.Tests.csproj -c Release --filter "FullyQualifiedName~GitHubActionsCacheCleanupServiceTests|FullyQualifiedName~RunnerHousekeepingServiceTests"

Notes

  • the composite action wrapper was simplified by moving repeated PowerShell into a helper script

@claude
Copy link

claude bot commented Mar 12, 2026

PR Review: Add reusable GitHub housekeeping workflows

Overall this is a well-structured addition. The service architecture is clean, the CLI refactor removes real duplication, and the composite action design (build-from-source at the referenced commit) is a reasonable explicit choice. Below are findings grouped by severity.


Bugs

1. DeleteTargets aborts on first failure, but step result always reports success
In RunnerHousekeepingService.cs, the loop over cleanup targets does not catch IOException for the non-sudo path. A single file-lock failure will throw an unhandled exception and terminate the whole step, yet the step-level StepResult has Success = true only if execution reaches the return. The net effect is that partial completion is indistinguishable from full success in dry-run mode, and a full abort in apply mode. Consider wrapping each DeleteTarget call in a try/catch that accumulates errors into the step result, similar to how GitHubActionsCacheCleanupService handles FailOnDeleteError.

2. FormatGiB truncates to 0 for sub-GiB values
(long)bytes / 1_073_741_824 performs integer division, so any value between 1 byte and ~1 GiB displays as 0 GiB. This is most misleading exactly when disk is critically low (e.g., "Free disk before cleanup: 0 GiB" when 500 MiB remain). Consider using Math.Round or formatting as a decimal (bytes / 1_073_741_824.0).

3. Pagination not exercised in tests
FakeGitHubCachesHandler returns all caches in a single page regardless of per_page/page query parameters. If the pagination while loop ever has an off-by-one error (e.g., stops one page early, or loops forever when the last page is exactly full), the tests would not catch it. Adding a multi-page scenario would close this gap.


Security

4. Token passed via CLI argument
--token passes the token as a command-line argument. On Linux, /proc/<pid>/cmdline is readable by any process running as the same UID, which could expose the secret. The composite action correctly routes the token through POWERFORGE_GITHUB_TOKEN (env var), so automated use is safe. For users calling the CLI directly, the --token-env path should be prominently recommended in the README and --help output.

5. RunSudoDelete does not verify the path stays within the runner root
sudo rm -rf "$target" is executed where target comes from filesystem enumeration under known runner directories. If a malicious action created a symlink in _actions or _tool-cache pointing outside the runner root, the sudo deletion would follow it. A guard like:

if (!target.StartsWith(expectedRoot, StringComparison.Ordinal))
    throw new InvalidOperationException($"Refusing to delete outside runner root: {target}");

before invoking sudo rm -rf would prevent this.


Code Quality

6. Duplicate ResolveGitHubIdentity overloads
There are two near-identical overloads of ResolveGitHubIdentity — one for GitHubArtifactCleanupSpec and one for GitHubActionsCacheCleanupSpec. These will drift if either spec gains new fields. A shared interface (e.g., IGitHubIdentitySpec) with Owner, Repo, and Token would eliminate the duplication.

7. ParseOptionalPositiveInt silently ignores 0
The method returns null for values < 1, so --min-free-gb 0 is silently discarded rather than rejected or treated as "no threshold". This differs from ParseRequiredInt which throws on out-of-range values. The divergence should be documented or aligned.

8. dotnet build on every action invocation, no cache
The composite action runs dotnet build before each use without NuGet package caching. For repositories calling this action on every push or on a 6-hour schedule, this adds measurable overhead. Adding a actions/cache step for ~/.nuget/packages and the obj/ directories would help.

9. cleanup-runner hardcoded to false in the hosted workflow
github-housekeeping.yml sets cleanup-runner: 'false' with no workflow_dispatch input to override it. This means the runner-cleanup feature cannot be tested through the hosted workflow without modifying the YAML. Either expose it as a dispatch input or add a comment explaining why it is permanently disabled for this repository.

10. TOCTOU race in DeleteDirectoryContents
DeleteTarget re-checks Directory.Exists(target) after enumeration. On a busy runner this is a minor race, not a security issue, but it could cause spurious errors if a directory is created/removed between enumeration and deletion. Not a blocking concern, but worth noting.


Minor / Nits

  • http:// is accepted by NormalizeApiBaseUri for GHES deployments — a brief comment stating this is intentional would prevent future "fix" PRs that restrict it to https://.
  • The action.yml token fallback expression ${{ inputs.github-token != '' && inputs.github-token || github.token }} works, but the idiomatic GitHub Actions form is ${{ inputs.github-token || github.token }} (empty string is falsy in expressions). Consider simplifying.
  • JSON output of RunnerHousekeepingResult includes the full Targets array for each step. For large tool caches this can be very verbose. A summary count might be more useful for most consumers.

The core logic is solid and the test infrastructure (fake HTTP handler, real temp filesystem tests) is a good foundation. Addressing the DeleteTargets error handling (#1) and the sudo rm -rf path guard (#5) would be the highest-priority changes before merge.

@claude
Copy link

claude bot commented Mar 12, 2026

test

@claude
Copy link

claude bot commented Mar 12, 2026

PSPublishModule - PowerShell Module

Description

This module is a module builder helper that helps build PowerShell modules "Evotec way".
It allows us to make sure our modules are built the same way every time making the process really easy to build and publish new versions.

Each module that is published to PowerShellGallery has Build or Publish folder next to it and within it there is PS1 script which executes module building that uses this module builder.

For examples and usage you can visit other projects and see how they are built in addition to what is shown in Build folder.

Support This Project

If you find this project helpful, please consider supporting its development.
Your sponsorship will help the maintainers dedicate more time to maintenance and new feature development for everyone.

It takes a lot of time and effort to create and maintain this project.
By becoming a sponsor, you can help ensure that it stays free and accessible to everyone who needs it.

To become a sponsor, you can choose from the following options:

Your sponsorship is completely optional and not required for using this project.
We want this project to remain open-source and available for anyone to use for free,
regardless of whether they choose to sponsor it or not.

If you work for a company that uses our .NET libraries or PowerShell Modules,
please consider asking your manager or marketing team if your company would be interested in supporting this project.
Your company's support can help us continue to maintain and improve this project for the benefit of everyone.

Thank you for considering supporting this project!

Installing

Install-Module -Name PSPublishModule -AllowClobber -Force -SkipPublisherCheck

Force and AllowClobber and SkipPublisherCheck aren't necessary but they do skip errors in case some appear.

Updating

Update-Module -Name PSPublishModule

That's it. Whenever there's a new version you simply run the command and you can enjoy it. Remember, that you may need to close, reopen the PowerShell session if you have already used the module before updating it.

The important thing is if something works for you on production, keep using it till you test the new version on a test computer. I do changes that may not be big, but big enough that auto-update will break your code. For example, small rename to a parameter and your code stops working! Be responsible!

Documentation - Local, GitHub, Azure DevOps

PSPublishModule can bundle your module's Internals (Scripts, Docs, Binaries, Config) and root README/CHANGELOG/LICENSE. Use PSMaintenance to view docs with a rich UI (README + CHANGELOG + License + Docs tabs, dependencies graph, commands help).

  • Build-time bundling (in your module's Build script):

    • New-ConfigurationInformation -IncludeAll 'Internals\'
    • New-ConfigurationDelivery -Enable -InternalsPath 'Internals' -IncludeRootReadme -IncludeRootChangelog -DocumentationOrder '01-Intro.md','02-HowTo.md'
    • Optional repository backfill (display docs directly from repo):
      • GitHub: New-ConfigurationDelivery -RepositoryPaths 'docs' -RepositoryBranch main
      • Azure DevOps: New-ConfigurationDelivery -RepositoryPaths 'Docs/en-US' -RepositoryBranch main
  • Copy docs for a module already installed:

    • Use the PSMaintenance module for viewing documentation locally.
  • View docs (PSMaintenance):

    • Local files: Show-ModuleDocumentation -Name 'EFAdminManager' -Readme -Changelog
    • Prefer repository: Show-ModuleDocumentation -Name 'EFAdminManager' -PreferRepository
    • Prefer Internals: Show-ModuleDocumentation -Name 'EFAdminManager' -Readme -Changelog -PreferInternals
    • Specific file from copied docs: Show-ModuleDocumentation -DocsPath 'C:\Docs\EFAdminManager\3.0.0' -File 'Internals\Docs\HowTo.md'
    • GitHub token (private repos): set once setx PG_GITHUB_TOKEN "ghp_xxx" or Set-ModuleDocumentation -GitHubToken 'ghp_xxx'
    • Azure DevOps PAT (Code: Read): set once setx PG_AZDO_PAT "your_pat" or Set-ModuleDocumentation -AzureDevOpsPat 'your_pat'
    • Repo URL format for AzDO: https://dev.azure.com/{organization}/{project}/_git/{repository}

Documentation Generation (Markdown, External Help, about_*)

PowerForge can generate PowerShell help from a built module:

  • Markdown help (PlatyPS-compatible): Docs/*.md
  • External help for Get-Help (MAML): <culture>/<ModuleName>-help.xml (default culture: en-US)
  • About topics (from about_*.help.txt / about_*.txt): converted to Docs/About/*.md and listed in Docs/Readme.md

To enable it in your build config:

  • New-ConfigurationDocumentation -Path 'Docs' -PathReadme 'Docs\\Readme.md'
  • New-ConfigurationDocumentation -Enable -UpdateWhenNew -StartClean -Path 'Docs' -PathReadme 'Docs\\Readme.md'
  • To also sync the generated external help file back into your project folder, add -SyncExternalHelpToProjectRoot

Authoring C# cmdlets (recommended):

  • Ensure <GenerateDocumentationFile>true</GenerateDocumentationFile> in your .csproj.
  • Add <example> blocks to the cmdlet class XML docs (PowerForge reads the .xml next to the published .dll):
/// <example>
/// <summary>Build a module</summary>
/// <code>Invoke-ModuleBuild -ModuleName 'MyModule' -Path 'C:\\Git\\MyModule'</code>
/// </example>

Authoring about_* topics:

  • Create en-US\\about_<Topic>.help.txt in your module source (standard PowerShell about help format).
  • Keep the culture folder in your packaged output (PowerForge includes en-US by default).
  • Opt out by omitting about_* files or setting BuildDocumentation.IncludeAboutTopics = false (or using New-ConfigurationDocumentation -SkipAboutTopics).

Usage

CLI (PowerForge.Cli)

This project includes a reusable C# core (PowerForge) and a CLI (powerforge) that can run builds/pipelines from JSON (CI/VSCode-friendly).
See JSON_SCHEMA.md for the shipped schema files and example pipeline configs.

GitHub artifact quota cleanup (safe defaults):

# dry-run by default (uses GITHUB_REPOSITORY + GITHUB_TOKEN)
powerforge github artifacts prune --name "test-results*,coverage*,github-pages"

# apply deletions
powerforge github artifacts prune --apply --keep 5 --max-age-days 7 --max-delete 200

GitHub cache cleanup and runner housekeeping:

# 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:

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.

New-PrepareModule -ModuleName "YourModule" -Path "C:\DirectoryWhereTheModuleWillBeCreated"

Once run the script will precrate the module folder with nessecary fies

NewPrepareModule

The structure of the module has all the basics that are required to start building the module. The module is ready to be built and published.

NewPrepareModuleStructure

The configuration example below shows how PSPublishModule is built internally. It's a good starting point for building your own module.
You can also look at the Examples and Build folders in this repository to see how it's used in real life.
Additionally all Evotec projects use this, so you can look at any of them to see how it's used.

Build-Module -ModuleName 'PSPublishModule' {
    # Usual defaults as per standard module
    $Manifest = [ordered] @{
        ModuleVersion          = '1.0.X'
        CompatiblePSEditions   = @('Desktop', 'Core')
        GUID                   = 'eb76426a-1992-40a5-82cd-6480f883ef4d'
        Author                 = 'Przemyslaw Klys'
        CompanyName            = 'Evotec'
        Copyright              = "(c) 2011 - $((Get-Date).Year) Przemyslaw Klys @ Evotec. All rights reserved."
        Description            = 'Simple project allowing preparing, managing, building and publishing modules to PowerShellGallery'
        PowerShellVersion      = '5.1'
        Tags                   = @('Windows', 'MacOS', 'Linux', 'Build', 'Module')
        IconUri                = 'https://evotec.xyz/wp-content/uploads/2019/02/PSPublishModule.png'
        ProjectUri             = 'https://github.com/EvotecIT/PSPublishModule'
        DotNetFrameworkVersion = '4.5.2'
    }
    New-ConfigurationManifest @Manifest

    # Add standard module dependencies (directly, but can be used with loop as well)
    New-ConfigurationModule -Type RequiredModule -Name 'powershellget' -Guid 'Auto' -Version 'Latest'
    New-ConfigurationModule -Type RequiredModule -Name 'PSScriptAnalyzer' -Guid 'Auto' -Version 'Latest'

    # Do not add inbox Microsoft.PowerShell.* modules as Required/External dependencies.

    # Add approved modules, that can be used as a dependency, but only when specific function from those modules is used
    # And on that time only that function and dependant functions will be copied over
    # Keep in mind it has it's limits when "copying" functions such as it should not depend on DLLs or other external files
    New-ConfigurationModule -Type ApprovedModule -Name 'PSSharedGoods', 'PSWriteColor', 'Connectimo', 'PSUnifi', 'PSWebToolbox', 'PSMyPassword'

    #New-ConfigurationModuleSkip -IgnoreFunctionName 'Invoke-Formatter', 'Find-Module'

    $ConfigurationFormat = [ordered] @{
        RemoveComments                              = $false

        PlaceOpenBraceEnable                        = $true
        PlaceOpenBraceOnSameLine                    = $true
        PlaceOpenBraceNewLineAfter                  = $true
        PlaceOpenBraceIgnoreOneLineBlock            = $false

        PlaceCloseBraceEnable                       = $true
        PlaceCloseBraceNewLineAfter                 = $true
        PlaceCloseBraceIgnoreOneLineBlock           = $false
        PlaceCloseBraceNoEmptyLineBefore            = $true

        UseConsistentIndentationEnable              = $true
        UseConsistentIndentationKind                = 'space'
        UseConsistentIndentationPipelineIndentation = 'IncreaseIndentationAfterEveryPipeline'
        UseConsistentIndentationIndentationSize     = 4

        UseConsistentWhitespaceEnable               = $true
        UseConsistentWhitespaceCheckInnerBrace      = $true
        UseConsistentWhitespaceCheckOpenBrace       = $true
        UseConsistentWhitespaceCheckOpenParen       = $true
        UseConsistentWhitespaceCheckOperator        = $true
        UseConsistentWhitespaceCheckPipe            = $true
        UseConsistentWhitespaceCheckSeparator       = $true

        AlignAssignmentStatementEnable              = $true
        AlignAssignmentStatementCheckHashtable      = $true

        UseCorrectCasingEnable                      = $true
    }
    # format PSD1 and PSM1 files when merging into a single file
    # enable formatting is not required as Configuration is provided
    New-ConfigurationFormat -ApplyTo 'OnMergePSM1', 'OnMergePSD1' -Sort None @ConfigurationFormat
    # format PSD1 and PSM1 files within the module
    # enable formatting is required to make sure that formatting is applied (with default settings)
    New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'DefaultPSM1' -EnableFormatting -Sort None
    # when creating PSD1 use special style without comments and with only required parameters
    New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'OnMergePSD1' -PSD1Style 'Minimal'

    # configuration for documentation, at the same time it enables documentation processing
    New-ConfigurationDocumentation -Enable:$false -StartClean -UpdateWhenNew -PathReadme 'Docs\Readme.md' -Path 'Docs'

    New-ConfigurationImportModule -ImportSelf -ImportRequiredModules

    New-ConfigurationBuild -Enable:$true -SignModule -DeleteTargetModuleBeforeBuild -MergeModuleOnBuild -CertificateThumbprint '36A8A2D0E227D81A2D3B60DCE0CFCF23BEFC343B'

    New-ConfigurationArtefact -Type Unpacked -Enable -Path "$PSScriptRoot\..\Artefacts" -RequiredModulesPath "$PSScriptRoot\..\Artefacts\Modules"
    New-ConfigurationArtefact -Type Packed -Enable -Path "$PSScriptRoot\..\Releases" -IncludeTagName

    # global options for publishing to github/psgallery
    New-ConfigurationPublish -Type PowerShellGallery -FilePath 'C:\Support\Important\PowerShellGalleryAPI.txt' -Enabled:$false
    New-ConfigurationPublish -Type GitHub -FilePath 'C:\Support\Important\GitHubAPI.txt' -UserName 'EvotecIT' -Enabled:$false
}

The old way still works, but is less preferred. It's kept for backwards compatibility.
It's much harder to discover what is what and how it impacts things. It's also harder to maintain.

$Configuration = @{
    Information = @{
        ModuleName        = 'PSPublishModule'
        #DirectoryProjects = 'C:\Support\GitHub'

        # Where from to export aliases / functions
        FunctionsToExport = 'Public'
        AliasesToExport   = 'Public'

        # Those options below are not nessecary but can be used to configure other options. Those are "defaults"
        Exclude           = '.*', 'Ignore', 'Examples', 'package.json', 'Publish', 'Docs'
        IncludeRoot       = '*.psm1', '*.psd1', 'License*'
        IncludePS1        = 'Private', 'Public', 'Enums', 'Classes'
        IncludeAll        = 'Images\', 'Resources\', 'Templates\', 'Bin\', 'Lib\', 'Data\'

        IncludeCustomCode = {

        }
        IncludeToArray    = @{
            'Rules' = 'Examples'
        }

        LibrariesCore     = 'Lib\Core'
        LibrariesDefault  = 'Lib\Default'
        LibrariesStandard = 'Lib\Standard'

        # manifest information
        Manifest          = @{
            # Version number of this module.
            ModuleVersion              = '1.0.0'
            # Supported PSEditions
            CompatiblePSEditions       = @('Desktop', 'Core')
            # ID used to uniquely identify this module
            GUID                       = 'eb76426a-1992-40a5-82cd-6480f883ef4d'
            # Author of this module
            Author                     = 'Przemyslaw Klys'
            # Company or vendor of this module
            CompanyName                = 'Evotec'
            # Copyright statement for this module
            Copyright                  = "(c) 2011 - $((Get-Date).Year) Przemyslaw Klys @ Evotec. All rights reserved."
            # Description of the functionality provided by this module
            Description                = 'Simple project allowing preparing, managing, building and publishing modules to PowerShellGallery'
            # Minimum version of the Windows PowerShell engine required by this module
            PowerShellVersion          = '5.1'
            # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
            Tags                       = @('Windows', 'MacOS', 'Linux', 'Build', 'Module')
            IconUri                    = 'https://evotec.xyz/wp-content/uploads/2019/02/PSPublishModule.png'
            ProjectUri                 = 'https://github.com/EvotecIT/PSPublishModule'

            RequiredModules            = @(
                @{ ModuleName = 'powershellget'; ModuleVersion = "2.2.5"; Guid = '1d73a601-4a6c-43c5-ba3f-619b18bbb404' }
                @{ ModuleName = 'PSScriptAnalyzer'; ModuleVersion = "Latest"; Guid = 'd6245802-193d-4068-a631-8863a4342a18' }
            )
            ExternalModuleDependencies = @()
            DotNetFrameworkVersion     = '4.5.2'
        }
    }
    Options     = @{
        Merge             = @{
            Sort           = 'None'
            FormatCodePSM1 = @{
                Enabled           = $false
                RemoveComments    = $false
                FormatterSettings = @{
                    IncludeRules = @(
                        'PSPlaceOpenBrace',
                        'PSPlaceCloseBrace',
                        'PSUseConsistentWhitespace',
                        'PSUseConsistentIndentation',
                        'PSAlignAssignmentStatement',
                        'PSUseCorrectCasing'
                    )

                    Rules        = @{
                        PSPlaceOpenBrace           = @{
                            Enable             = $true
                            OnSameLine         = $true
                            NewLineAfter       = $true
                            IgnoreOneLineBlock = $true
                        }

                        PSPlaceCloseBrace          = @{
                            Enable             = $true
                            NewLineAfter       = $false
                            IgnoreOneLineBlock = $true
                            NoEmptyLineBefore  = $false
                        }

                        PSUseConsistentIndentation = @{
                            Enable              = $true
                            Kind                = 'space'
                            PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline'
                            IndentationSize     = 4
                        }

                        PSUseConsistentWhitespace  = @{
                            Enable          = $true
                            CheckInnerBrace = $true
                            CheckOpenBrace  = $true
                            CheckOpenParen  = $true
                            CheckOperator   = $true
                            CheckPipe       = $true
                            CheckSeparator  = $true
                        }

                        PSAlignAssignmentStatement = @{
                            Enable         = $true
                            CheckHashtable = $true
                        }

                        PSUseCorrectCasing         = @{
                            Enable = $true
                        }
                    }
                }
            }
            FormatCodePSD1 = @{
                Enabled        = $true
                RemoveComments = $false
            }
            Integrate      = @{
                ApprovedModules = 'PSSharedGoods', 'PSWriteColor', 'Connectimo', 'PSUnifi', 'PSWebToolbox', 'PSMyPassword'
            }
            # Style          = @{
            #     PSD1 = 'Native'
            # }
        }
        Standard          = @{
            FormatCodePSM1 = @{

            }
            FormatCodePSD1 = @{
                Enabled = $true
                #RemoveComments = $true
            }
            # Style          = @{
            #     PSD1 = 'Native'
            # }
        }
        PowerShellGallery = @{
            ApiKey   = 'C:\Support\Important\PowerShellGalleryAPI.txt'
            FromFile = $true
        }
        GitHub            = @{
            ApiKey   = 'C:\Support\Important\GithubAPI.txt'
            FromFile = $true
            UserName = 'EvotecIT'
            #RepositoryName = 'PSPublishModule' # not required, uses project name
        }
        Documentation     = @{
            Path       = 'Docs'
            PathReadme = 'Docs\Readme.md'
        }
        Style             = @{
            PSD1 = 'Minimal' # Native
        }
        Signing           = @{
            CertificateThumbprint = '36A8A2D0E227D81A2D3B60DCE0CFCF23BEFC343B'
        }
    }
    Steps       = @{
        BuildLibraries     = @{
            Enable        = $false # build once every time nuget gets updated
            Configuration = 'Release'
            Framework     = 'netstandard2.0', 'net472'
            #ProjectName   = 'ImagePlayground.PowerShell'
        }
        BuildModule        = @{  # requires Enable to be on to process all of that
            Enable                  = $true
            DeleteBefore            = $false
            Merge                   = $true
            MergeMissing            = $true
            SignMerged              = $true
            CreateFileCatalog       = $false
            Releases                = $true
            #ReleasesUnpacked        = $false
            ReleasesUnpacked        = @{
                Enabled         = $true
                IncludeTagName  = $false
                Path            = "$PSScriptRoot\..\Artefacts"
                RequiredModules = @{
                    Enabled = $true
                    Path    = "$PSScriptRoot\..\Artefacts\Modules"
                }
                DirectoryOutput = @{

                }
                FilesOutput     = @{

                }
            }
            RefreshPSD1Only         = $false
            # only when there are classes
            ClassesDotSource        = $false
            LibrarySeparateFile     = $false
            LibraryDotSource        = $false
            # Applicable only for non-merge/publish situation
            # It's simply to make life easier during debugging
            # It makes all functions/aliases exportable
            UseWildcardForFunctions = $false

            # special features for binary modules
            DebugDLL                = $false
            ResolveBinaryConflicts  = $false # mostly for memory and other libraries
            # ResolveBinaryConflicts  = @{
            #     ProjectName = 'ImagePlayground.PowerShell'
            # }
            LocalVersion            = $false # bumps version in PSD1 on every build
        }
        BuildDocumentation = @{
            Enable        = $true # enables documentation processing
            StartClean    = $true # always starts clean
            UpdateWhenNew = $true # always updates right after update
        }
        ImportModules      = @{
            Self            = $true
            RequiredModules = $false
            Verbose         = $false
        }
        PublishModule      = @{  # requires Enable to be on to process all of that
            Enabled      = $false
            Prerelease   = ''
            RequireForce = $false
            GitHub       = $false
        }
    }
}

Build-Module -Configuration $Configuration

1 similar comment
@claude
Copy link

claude bot commented Mar 12, 2026

PSPublishModule - PowerShell Module

Description

This module is a module builder helper that helps build PowerShell modules "Evotec way".
It allows us to make sure our modules are built the same way every time making the process really easy to build and publish new versions.

Each module that is published to PowerShellGallery has Build or Publish folder next to it and within it there is PS1 script which executes module building that uses this module builder.

For examples and usage you can visit other projects and see how they are built in addition to what is shown in Build folder.

Support This Project

If you find this project helpful, please consider supporting its development.
Your sponsorship will help the maintainers dedicate more time to maintenance and new feature development for everyone.

It takes a lot of time and effort to create and maintain this project.
By becoming a sponsor, you can help ensure that it stays free and accessible to everyone who needs it.

To become a sponsor, you can choose from the following options:

Your sponsorship is completely optional and not required for using this project.
We want this project to remain open-source and available for anyone to use for free,
regardless of whether they choose to sponsor it or not.

If you work for a company that uses our .NET libraries or PowerShell Modules,
please consider asking your manager or marketing team if your company would be interested in supporting this project.
Your company's support can help us continue to maintain and improve this project for the benefit of everyone.

Thank you for considering supporting this project!

Installing

Install-Module -Name PSPublishModule -AllowClobber -Force -SkipPublisherCheck

Force and AllowClobber and SkipPublisherCheck aren't necessary but they do skip errors in case some appear.

Updating

Update-Module -Name PSPublishModule

That's it. Whenever there's a new version you simply run the command and you can enjoy it. Remember, that you may need to close, reopen the PowerShell session if you have already used the module before updating it.

The important thing is if something works for you on production, keep using it till you test the new version on a test computer. I do changes that may not be big, but big enough that auto-update will break your code. For example, small rename to a parameter and your code stops working! Be responsible!

Documentation - Local, GitHub, Azure DevOps

PSPublishModule can bundle your module's Internals (Scripts, Docs, Binaries, Config) and root README/CHANGELOG/LICENSE. Use PSMaintenance to view docs with a rich UI (README + CHANGELOG + License + Docs tabs, dependencies graph, commands help).

  • Build-time bundling (in your module's Build script):

    • New-ConfigurationInformation -IncludeAll 'Internals\'
    • New-ConfigurationDelivery -Enable -InternalsPath 'Internals' -IncludeRootReadme -IncludeRootChangelog -DocumentationOrder '01-Intro.md','02-HowTo.md'
    • Optional repository backfill (display docs directly from repo):
      • GitHub: New-ConfigurationDelivery -RepositoryPaths 'docs' -RepositoryBranch main
      • Azure DevOps: New-ConfigurationDelivery -RepositoryPaths 'Docs/en-US' -RepositoryBranch main
  • Copy docs for a module already installed:

    • Use the PSMaintenance module for viewing documentation locally.
  • View docs (PSMaintenance):

    • Local files: Show-ModuleDocumentation -Name 'EFAdminManager' -Readme -Changelog
    • Prefer repository: Show-ModuleDocumentation -Name 'EFAdminManager' -PreferRepository
    • Prefer Internals: Show-ModuleDocumentation -Name 'EFAdminManager' -Readme -Changelog -PreferInternals
    • Specific file from copied docs: Show-ModuleDocumentation -DocsPath 'C:\Docs\EFAdminManager\3.0.0' -File 'Internals\Docs\HowTo.md'
    • GitHub token (private repos): set once setx PG_GITHUB_TOKEN "ghp_xxx" or Set-ModuleDocumentation -GitHubToken 'ghp_xxx'
    • Azure DevOps PAT (Code: Read): set once setx PG_AZDO_PAT "your_pat" or Set-ModuleDocumentation -AzureDevOpsPat 'your_pat'
    • Repo URL format for AzDO: https://dev.azure.com/{organization}/{project}/_git/{repository}

Documentation Generation (Markdown, External Help, about_*)

PowerForge can generate PowerShell help from a built module:

  • Markdown help (PlatyPS-compatible): Docs/*.md
  • External help for Get-Help (MAML): <culture>/<ModuleName>-help.xml (default culture: en-US)
  • About topics (from about_*.help.txt / about_*.txt): converted to Docs/About/*.md and listed in Docs/Readme.md

To enable it in your build config:

  • New-ConfigurationDocumentation -Path 'Docs' -PathReadme 'Docs\\Readme.md'
  • New-ConfigurationDocumentation -Enable -UpdateWhenNew -StartClean -Path 'Docs' -PathReadme 'Docs\\Readme.md'
  • To also sync the generated external help file back into your project folder, add -SyncExternalHelpToProjectRoot

Authoring C# cmdlets (recommended):

  • Ensure <GenerateDocumentationFile>true</GenerateDocumentationFile> in your .csproj.
  • Add <example> blocks to the cmdlet class XML docs (PowerForge reads the .xml next to the published .dll):
/// <example>
/// <summary>Build a module</summary>
/// <code>Invoke-ModuleBuild -ModuleName 'MyModule' -Path 'C:\\Git\\MyModule'</code>
/// </example>

Authoring about_* topics:

  • Create en-US\\about_<Topic>.help.txt in your module source (standard PowerShell about help format).
  • Keep the culture folder in your packaged output (PowerForge includes en-US by default).
  • Opt out by omitting about_* files or setting BuildDocumentation.IncludeAboutTopics = false (or using New-ConfigurationDocumentation -SkipAboutTopics).

Usage

CLI (PowerForge.Cli)

This project includes a reusable C# core (PowerForge) and a CLI (powerforge) that can run builds/pipelines from JSON (CI/VSCode-friendly).
See JSON_SCHEMA.md for the shipped schema files and example pipeline configs.

GitHub artifact quota cleanup (safe defaults):

# dry-run by default (uses GITHUB_REPOSITORY + GITHUB_TOKEN)
powerforge github artifacts prune --name "test-results*,coverage*,github-pages"

# apply deletions
powerforge github artifacts prune --apply --keep 5 --max-age-days 7 --max-delete 200

GitHub cache cleanup and runner housekeeping:

# 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:

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.

New-PrepareModule -ModuleName "YourModule" -Path "C:\DirectoryWhereTheModuleWillBeCreated"

Once run the script will precrate the module folder with nessecary fies

NewPrepareModule

The structure of the module has all the basics that are required to start building the module. The module is ready to be built and published.

NewPrepareModuleStructure

The configuration example below shows how PSPublishModule is built internally. It's a good starting point for building your own module.
You can also look at the Examples and Build folders in this repository to see how it's used in real life.
Additionally all Evotec projects use this, so you can look at any of them to see how it's used.

Build-Module -ModuleName 'PSPublishModule' {
    # Usual defaults as per standard module
    $Manifest = [ordered] @{
        ModuleVersion          = '1.0.X'
        CompatiblePSEditions   = @('Desktop', 'Core')
        GUID                   = 'eb76426a-1992-40a5-82cd-6480f883ef4d'
        Author                 = 'Przemyslaw Klys'
        CompanyName            = 'Evotec'
        Copyright              = "(c) 2011 - $((Get-Date).Year) Przemyslaw Klys @ Evotec. All rights reserved."
        Description            = 'Simple project allowing preparing, managing, building and publishing modules to PowerShellGallery'
        PowerShellVersion      = '5.1'
        Tags                   = @('Windows', 'MacOS', 'Linux', 'Build', 'Module')
        IconUri                = 'https://evotec.xyz/wp-content/uploads/2019/02/PSPublishModule.png'
        ProjectUri             = 'https://github.com/EvotecIT/PSPublishModule'
        DotNetFrameworkVersion = '4.5.2'
    }
    New-ConfigurationManifest @Manifest

    # Add standard module dependencies (directly, but can be used with loop as well)
    New-ConfigurationModule -Type RequiredModule -Name 'powershellget' -Guid 'Auto' -Version 'Latest'
    New-ConfigurationModule -Type RequiredModule -Name 'PSScriptAnalyzer' -Guid 'Auto' -Version 'Latest'

    # Do not add inbox Microsoft.PowerShell.* modules as Required/External dependencies.

    # Add approved modules, that can be used as a dependency, but only when specific function from those modules is used
    # And on that time only that function and dependant functions will be copied over
    # Keep in mind it has it's limits when "copying" functions such as it should not depend on DLLs or other external files
    New-ConfigurationModule -Type ApprovedModule -Name 'PSSharedGoods', 'PSWriteColor', 'Connectimo', 'PSUnifi', 'PSWebToolbox', 'PSMyPassword'

    #New-ConfigurationModuleSkip -IgnoreFunctionName 'Invoke-Formatter', 'Find-Module'

    $ConfigurationFormat = [ordered] @{
        RemoveComments                              = $false

        PlaceOpenBraceEnable                        = $true
        PlaceOpenBraceOnSameLine                    = $true
        PlaceOpenBraceNewLineAfter                  = $true
        PlaceOpenBraceIgnoreOneLineBlock            = $false

        PlaceCloseBraceEnable                       = $true
        PlaceCloseBraceNewLineAfter                 = $true
        PlaceCloseBraceIgnoreOneLineBlock           = $false
        PlaceCloseBraceNoEmptyLineBefore            = $true

        UseConsistentIndentationEnable              = $true
        UseConsistentIndentationKind                = 'space'
        UseConsistentIndentationPipelineIndentation = 'IncreaseIndentationAfterEveryPipeline'
        UseConsistentIndentationIndentationSize     = 4

        UseConsistentWhitespaceEnable               = $true
        UseConsistentWhitespaceCheckInnerBrace      = $true
        UseConsistentWhitespaceCheckOpenBrace       = $true
        UseConsistentWhitespaceCheckOpenParen       = $true
        UseConsistentWhitespaceCheckOperator        = $true
        UseConsistentWhitespaceCheckPipe            = $true
        UseConsistentWhitespaceCheckSeparator       = $true

        AlignAssignmentStatementEnable              = $true
        AlignAssignmentStatementCheckHashtable      = $true

        UseCorrectCasingEnable                      = $true
    }
    # format PSD1 and PSM1 files when merging into a single file
    # enable formatting is not required as Configuration is provided
    New-ConfigurationFormat -ApplyTo 'OnMergePSM1', 'OnMergePSD1' -Sort None @ConfigurationFormat
    # format PSD1 and PSM1 files within the module
    # enable formatting is required to make sure that formatting is applied (with default settings)
    New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'DefaultPSM1' -EnableFormatting -Sort None
    # when creating PSD1 use special style without comments and with only required parameters
    New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'OnMergePSD1' -PSD1Style 'Minimal'

    # configuration for documentation, at the same time it enables documentation processing
    New-ConfigurationDocumentation -Enable:$false -StartClean -UpdateWhenNew -PathReadme 'Docs\Readme.md' -Path 'Docs'

    New-ConfigurationImportModule -ImportSelf -ImportRequiredModules

    New-ConfigurationBuild -Enable:$true -SignModule -DeleteTargetModuleBeforeBuild -MergeModuleOnBuild -CertificateThumbprint '36A8A2D0E227D81A2D3B60DCE0CFCF23BEFC343B'

    New-ConfigurationArtefact -Type Unpacked -Enable -Path "$PSScriptRoot\..\Artefacts" -RequiredModulesPath "$PSScriptRoot\..\Artefacts\Modules"
    New-ConfigurationArtefact -Type Packed -Enable -Path "$PSScriptRoot\..\Releases" -IncludeTagName

    # global options for publishing to github/psgallery
    New-ConfigurationPublish -Type PowerShellGallery -FilePath 'C:\Support\Important\PowerShellGalleryAPI.txt' -Enabled:$false
    New-ConfigurationPublish -Type GitHub -FilePath 'C:\Support\Important\GitHubAPI.txt' -UserName 'EvotecIT' -Enabled:$false
}

The old way still works, but is less preferred. It's kept for backwards compatibility.
It's much harder to discover what is what and how it impacts things. It's also harder to maintain.

$Configuration = @{
    Information = @{
        ModuleName        = 'PSPublishModule'
        #DirectoryProjects = 'C:\Support\GitHub'

        # Where from to export aliases / functions
        FunctionsToExport = 'Public'
        AliasesToExport   = 'Public'

        # Those options below are not nessecary but can be used to configure other options. Those are "defaults"
        Exclude           = '.*', 'Ignore', 'Examples', 'package.json', 'Publish', 'Docs'
        IncludeRoot       = '*.psm1', '*.psd1', 'License*'
        IncludePS1        = 'Private', 'Public', 'Enums', 'Classes'
        IncludeAll        = 'Images\', 'Resources\', 'Templates\', 'Bin\', 'Lib\', 'Data\'

        IncludeCustomCode = {

        }
        IncludeToArray    = @{
            'Rules' = 'Examples'
        }

        LibrariesCore     = 'Lib\Core'
        LibrariesDefault  = 'Lib\Default'
        LibrariesStandard = 'Lib\Standard'

        # manifest information
        Manifest          = @{
            # Version number of this module.
            ModuleVersion              = '1.0.0'
            # Supported PSEditions
            CompatiblePSEditions       = @('Desktop', 'Core')
            # ID used to uniquely identify this module
            GUID                       = 'eb76426a-1992-40a5-82cd-6480f883ef4d'
            # Author of this module
            Author                     = 'Przemyslaw Klys'
            # Company or vendor of this module
            CompanyName                = 'Evotec'
            # Copyright statement for this module
            Copyright                  = "(c) 2011 - $((Get-Date).Year) Przemyslaw Klys @ Evotec. All rights reserved."
            # Description of the functionality provided by this module
            Description                = 'Simple project allowing preparing, managing, building and publishing modules to PowerShellGallery'
            # Minimum version of the Windows PowerShell engine required by this module
            PowerShellVersion          = '5.1'
            # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
            Tags                       = @('Windows', 'MacOS', 'Linux', 'Build', 'Module')
            IconUri                    = 'https://evotec.xyz/wp-content/uploads/2019/02/PSPublishModule.png'
            ProjectUri                 = 'https://github.com/EvotecIT/PSPublishModule'

            RequiredModules            = @(
                @{ ModuleName = 'powershellget'; ModuleVersion = "2.2.5"; Guid = '1d73a601-4a6c-43c5-ba3f-619b18bbb404' }
                @{ ModuleName = 'PSScriptAnalyzer'; ModuleVersion = "Latest"; Guid = 'd6245802-193d-4068-a631-8863a4342a18' }
            )
            ExternalModuleDependencies = @()
            DotNetFrameworkVersion     = '4.5.2'
        }
    }
    Options     = @{
        Merge             = @{
            Sort           = 'None'
            FormatCodePSM1 = @{
                Enabled           = $false
                RemoveComments    = $false
                FormatterSettings = @{
                    IncludeRules = @(
                        'PSPlaceOpenBrace',
                        'PSPlaceCloseBrace',
                        'PSUseConsistentWhitespace',
                        'PSUseConsistentIndentation',
                        'PSAlignAssignmentStatement',
                        'PSUseCorrectCasing'
                    )

                    Rules        = @{
                        PSPlaceOpenBrace           = @{
                            Enable             = $true
                            OnSameLine         = $true
                            NewLineAfter       = $true
                            IgnoreOneLineBlock = $true
                        }

                        PSPlaceCloseBrace          = @{
                            Enable             = $true
                            NewLineAfter       = $false
                            IgnoreOneLineBlock = $true
                            NoEmptyLineBefore  = $false
                        }

                        PSUseConsistentIndentation = @{
                            Enable              = $true
                            Kind                = 'space'
                            PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline'
                            IndentationSize     = 4
                        }

                        PSUseConsistentWhitespace  = @{
                            Enable          = $true
                            CheckInnerBrace = $true
                            CheckOpenBrace  = $true
                            CheckOpenParen  = $true
                            CheckOperator   = $true
                            CheckPipe       = $true
                            CheckSeparator  = $true
                        }

                        PSAlignAssignmentStatement = @{
                            Enable         = $true
                            CheckHashtable = $true
                        }

                        PSUseCorrectCasing         = @{
                            Enable = $true
                        }
                    }
                }
            }
            FormatCodePSD1 = @{
                Enabled        = $true
                RemoveComments = $false
            }
            Integrate      = @{
                ApprovedModules = 'PSSharedGoods', 'PSWriteColor', 'Connectimo', 'PSUnifi', 'PSWebToolbox', 'PSMyPassword'
            }
            # Style          = @{
            #     PSD1 = 'Native'
            # }
        }
        Standard          = @{
            FormatCodePSM1 = @{

            }
            FormatCodePSD1 = @{
                Enabled = $true
                #RemoveComments = $true
            }
            # Style          = @{
            #     PSD1 = 'Native'
            # }
        }
        PowerShellGallery = @{
            ApiKey   = 'C:\Support\Important\PowerShellGalleryAPI.txt'
            FromFile = $true
        }
        GitHub            = @{
            ApiKey   = 'C:\Support\Important\GithubAPI.txt'
            FromFile = $true
            UserName = 'EvotecIT'
            #RepositoryName = 'PSPublishModule' # not required, uses project name
        }
        Documentation     = @{
            Path       = 'Docs'
            PathReadme = 'Docs\Readme.md'
        }
        Style             = @{
            PSD1 = 'Minimal' # Native
        }
        Signing           = @{
            CertificateThumbprint = '36A8A2D0E227D81A2D3B60DCE0CFCF23BEFC343B'
        }
    }
    Steps       = @{
        BuildLibraries     = @{
            Enable        = $false # build once every time nuget gets updated
            Configuration = 'Release'
            Framework     = 'netstandard2.0', 'net472'
            #ProjectName   = 'ImagePlayground.PowerShell'
        }
        BuildModule        = @{  # requires Enable to be on to process all of that
            Enable                  = $true
            DeleteBefore            = $false
            Merge                   = $true
            MergeMissing            = $true
            SignMerged              = $true
            CreateFileCatalog       = $false
            Releases                = $true
            #ReleasesUnpacked        = $false
            ReleasesUnpacked        = @{
                Enabled         = $true
                IncludeTagName  = $false
                Path            = "$PSScriptRoot\..\Artefacts"
                RequiredModules = @{
                    Enabled = $true
                    Path    = "$PSScriptRoot\..\Artefacts\Modules"
                }
                DirectoryOutput = @{

                }
                FilesOutput     = @{

                }
            }
            RefreshPSD1Only         = $false
            # only when there are classes
            ClassesDotSource        = $false
            LibrarySeparateFile     = $false
            LibraryDotSource        = $false
            # Applicable only for non-merge/publish situation
            # It's simply to make life easier during debugging
            # It makes all functions/aliases exportable
            UseWildcardForFunctions = $false

            # special features for binary modules
            DebugDLL                = $false
            ResolveBinaryConflicts  = $false # mostly for memory and other libraries
            # ResolveBinaryConflicts  = @{
            #     ProjectName = 'ImagePlayground.PowerShell'
            # }
            LocalVersion            = $false # bumps version in PSD1 on every build
        }
        BuildDocumentation = @{
            Enable        = $true # enables documentation processing
            StartClean    = $true # always starts clean
            UpdateWhenNew = $true # always updates right after update
        }
        ImportModules      = @{
            Self            = $true
            RequiredModules = $false
            Verbose         = $false
        }
        PublishModule      = @{  # requires Enable to be on to process all of that
            Enabled      = $false
            Prerelease   = ''
            RequireForce = $false
            GitHub       = $false
        }
    }
}

Build-Module -Configuration $Configuration

@claude
Copy link

claude bot commented Mar 12, 2026

Backlog

PowerForge.Web (Engine)

API Docs parity (DocFX-style)

  • Render XML tags beyond summary/remarks: example, exception, value, typeparam, seealso
  • Add access modifiers + async/readonly/static/virtual info in signatures
  • Group overloads and surface constructors separately
  • Optional source links per type/member (GitHub line mapping)
  • Optional per‑type mini‑TOC (“In this article”)
  • Type hierarchy tree (base chain + derived types when available)

Engine quality

  • Validate layout hooks (extra_css_html / extra_scripts_html) so per-page assets load.
  • Warn when Prism local assets are missing (prevent silent 404s).
  • Prism theme overrides (light/dark) for reusable site theming.
  • Pipeline defaults: include audit step (links/assets/nav/rendered).
  • See Docs/PowerForge.Web.Backlog.md for full engine parity + roadmap.

PSPublishModule / PowerForge (DotNet Publish Engine)

  • Cross-repo build replacement implementation plan:
    • Docs/PSPublishModule.DotNetPublish.ImplementationPlan.md

Websites

@claude
Copy link

claude bot commented Mar 12, 2026

hello world

@claude
Copy link

claude bot commented Mar 12, 2026

PSPublishModule Changelog

2.0.19 - 2025.06.17

What's Changed

Full Changelog: v2.0.18...v2.0.19

2.0.18 - 2025.06.06

What's new

  • Update to docs and small logic improved for Get-ProjectVersion and Set-ProjectVersion

2.0.17 - 2025.05.28

  • Make sure to error out on PSM1 file missing
  • Added support for .NET 8.0
  • Added Set-ProjectVersion cmdlet to allow for setting version of the project across (csproj/psd1/ps1) files for consistency in multi-file projects
  • Added Get-ProjectVersion cmdlet to allow for getting version of the project across (csproj/psd1/ps1) files for consistency in multi-file projects
Get-ProjectVersion -Path "C:\Support\GitHub\DnsClientX" -ExcludeFolders @('C:\Support\GitHub\DnsClientX\Module\Artefacts')
Set-ProjectVersion -Path "C:\Support\GitHub\DnsClientX" -NewVersion "0.4.0"
Set-ProjectVersion -Path "C:\Support\GitHub\DnsClientX" -VersionType Build

2.0.16 - 2024.10.27

  • Improve error handling

2.0.15 - 2024.10.11

What's Changed

  • Update BuildModule.yml by @SamErde in Update BuildModule.yml #27
  • Improve error handling
  • Improve DLL handling
  • Add new options for binary cmdlet scanning
  • Fixes detecting cmdlets in binary modules for PS 5.1
  • Other small improvements

New Contributors

Full Changelog: v2.0.14...v2.0.15

2.0.14 - 2024.08.14

What's Changed

Full Changelog: v2.0.13...v2.0.14

2.0.13 - 2024.07.13

  • When using New-ConfigurtionManifest and using CmdletsToExport it would not export them in PSM1 file. This is now fixed
  • Moved function to separate file to improve visibility

2.0.12 - 2024.05.06

  • Always publish to Github with PreReleaseTag if it's set
  • Added support for HelpOut module to be used for help generation (platyPS will stay default for now)
  • Added support for NETIgnoreLibraryOnLoad in New-ConfigurationBuild to allow for ignoring libraries on load (for example C/C++ dlls)
  • Added support for NETBinaryModule in New-ConfigurationBuild to allow for building binary modules (C#) and doing Import-Module in PSM1 for them
  • Improves detection of used functions/aliases in module (now it's more accurate)
  • When using New-ConfigurationArtefact with Script artefact type and removal of comments it would break the whole script. This is now fixed
  • Fixes case where during binary module building the DLL would not be used at all
  • Improvement to TargetFramework and TargetFrameworks support when using C# projects. TargetFrameworkVersion is not supported.
  • Added support for NETHandleAssemblyWithSameName in New-ConfigurationBuild to allow for handling of assemblies with same name (for example Newtonsoft.Json)
  • Improved loading of libraries to use foreach instead of loading one by one
  • Removed dependency on PlatyPS/HelpOut. Now it's optional and can be used if needed. HelpOut does have some issues that I hope will be solved sooner or later
  • Improved creating empty PSM1 file when Public/Private folders have no files or are not present
  • Added NETProjectPath to New-ConfigurationBuild to allow for specifying path to project file (csproj) when building binary modules
  • Improve New-ConfigurationManifest allowing to define FunctionsToExport and CmdletsToExport and AliasesToExport to allow for more control over exported functions/cmdlets/aliases (by default Aliaeses/Functions are exported but you can overwrite them)

2.0.11

  • Fix for broken script generation if New-ConfigurationCommand is used

2.0.10

  • Fix for paths that I've broken in 2.0.9

2.0.9

  • Added usage of OpenAuthenticode from @jborean93 to allow for signing of modules on Linux/MacOS
    • The module is not automatically installed, you need to install it yourself
    • May need to be updated, as haven't really tested it

2.0.8

  • Improve error handling
  • Added NETExcludeMainLibrary to not include Initialize library if no logic is required
  • Added NETExcludeLibraryFilter to allow for excluding libraries from being copied on build
  • Added PreScriptMergePath and PostScriptMergePath to allow for files implementation instead of direct code in New-ConfigurationArtefact
    • This supplements using PreScriptMerge and PostScriptMerge (which are still supported, but overwritten when using PreScriptMergePath and PostScriptMergePath)

2.0.7

  • Added to New-ConfigurationArtefact replacement of variables in paths (for example <TagModuleVersionWithPreRelease>)
  • Added to New-ConfigurationArtefact - DoNotClear parameter to allow for not clearing of artefact folder, by default it will delete it's content with excluded .zip files (if any)

2.0.6

  • Fixes problem with defaults such as IncludeAll, IncludePS1 and similar always being set preventing user from overwriting values on demand

2.0.5

What's new

  • Fixes remove comments functionality
  • Added remove empty lines functionality
  • Small fixes on PSD1 generation (sorting fixed)

Full Changelog: v2.0.4...v2.0.5

2.0.4

What's Changed

New Contributors

Full Changelog: v2.0.3...v2.0.4

2.0.3

  • Fix for non-terminating error when can't delete folders

2.0.2 - 2023.08.06

  • Fail build script if copying of module fails

2.0.1 - 2023.08.04

  • Improve New-ConfigurationArtefact to allow for more flexibility for "scripts"
    • Added PreScriptMerge and PostScriptMerge
    • Remove ScriptMerge (renamed to PostScriptMerge)
    • Added ScriptName to allow for custom name of script (renames the file from usual module name)

2.0.0 - 2023.08.03

  • Fixes to Import-Module functionality

1.8.0-Preview5

  • Fixes wrong path for packed zip

1.8.0-Preview4

  • Added handling of wrong certificate thumbrint
  • Prevent failure on required modules
  • Stop on missing requirede module

1.8.0-Preview3

  • Fixes ModuleVersionWithPreRelease -and TagModuleVersionWithPreRelease tags

1.8.0-Preview2

  • Fix for old configuration supporting releases and releases unpacked
  • Fix for ModuleName in old configuration
  • Allow to publish module Artefacts to GitHub using single New-ConfigurationPublish by marking multiple artefacts with same ID
  • Small visual improvements

1.8.0-Preview1

  • Added RequiredVersion to New-ConfigurationModule to allow specifying exact version instead the minimum version
  • Minor bugfix for potential issue with CommandModuleDependencies
  • Added ability to create multiple artefacts (not just 2) by using New-ConfigurationArtefact
  • Added ability to pick from 4 types of artefacts (Unpacked, Packed, Script, PackedScript)
    • Unpacked - unpacked module
    • Packed - packed module (as zip)
    • Script - script that is module in form of PS1 without PSD1 - only applicable to very simple modules
    • PackedScript - packed module (as zip) that is script that is module in form of PS1 without PSD1 - only applicable to very simple modules
  • Added ability to publish multiple artefacts (not just 2) by using New-ConfigurationPublish
  • Removed PreReleaseTag from New-ConfigurationPublish
  • Added PreReleaseTag to New-ConfigurationManifest to allow specifying pre-release tag
  • PreReleaseTag is autoamtically used for GitHub publishing when Manifest is set to prerelease

1.7.0

  • Improve New-ConfigurationBuild by adding CertificatePFXPath, CertificatePFXBase64, CertificatePFXPassword to support GitHub Actions/AzureDevOps
  • Fixes copy of files during build process
  • Fix exit code when build fails

1.6.0 - 2023.07.11

  • Improve logic for replacing expandable variables and allow disabling this feature
  • Impovements overall

1.5.1 - 2023.07.06

  • Improve logic for detection of used functions
  • Update default configuration for new modules

1.5.0 - 2023.07.04

  • Fixes detection of unknown functions in module

1.4.0 - 2023.07.03

  • Fixes minor issue with reading required modules

1.3.0 - 2023.07.03

  • Improve termination when things go wrong
  • Fix replacement of GUID in module manifest when creating new module
  • Fix for duplicate detection of used functions

1.2.0 - 2023.06.23

  • Improve termination when things go wrong
  • Small fixes here and there
  • Added ability to test after merge before publishing

1.1.0 - 2023.06.19

  • Improve initially provided files when creating new module for easy use
  • Autogenerate the example Build-Module.ps1 file for new projects
  • Update GUID/ModuleName based on data provided by user
  • Other small improvements

1.0.0 - 2023.06.18

  • Big rewrite of PSPublishModule allowing more configuration options
  • New configuration options
  • New safety checks in place

0.9.53 - 2022.12.31

  • Adds local versioning via BuildModule\LocalVersion property (bumps version on every build)
  • No longer delete older tagged unpacked releases (only current) when tagging is enabled
  • Allows to specify Releases property in BuildModule\Releases. Both Enabled/Path need to be specified. If not specified it will use default values.

0.9.52 - 2022.12.30

  • Changed loading of libraries so it's always done in following way:
  • If Standard, Core and Default are present -> load Default when PS 5, load Standard when PS 6+
  • If Standard, Default are present -> load Default when PS 5, load Standard when PS 6+
  • If Standard and Core are present -> load Standard only
  • If Core and Default are present -> load Default when PS 5, load Core when PS 6+
  • If Standard is present -> load Standard only for all PowerShell

I've removed the logic where Standard would always get loaded even if Default/Core was also provided which could lead to unexpected behavior.

  • Added ability to build libraries on demand (when using -BuildLibraries). Requires more testing.
  • Cleaned code a bit, moved things around
  • Added ability to ResolveBinaryConflicts (when using -ResolveBinaryConflicts). Requires more testing. Visible in project ImagePlayground
  • Removed need for DirectoryProjects in $Configuration.Information - if not present it will get the value itself

0.9.51 - 2022.09.26

  • Fixed path behavior for ReleasedUnpacked (hopefully)

0.9.43 - 2022.04.14

  • Small fixes for publishing modules with Standard Libraries only
  • Improved building of Artefacts

@claude
Copy link

claude bot commented Mar 12, 2026

Test: allow-sudo enables sudo rm

@claude
Copy link

claude bot commented Mar 12, 2026

Test: cleanup-runner: false in workflow

@claude
Copy link

claude bot commented Mar 12, 2026

forwarded via the token flag on command line. Use token-env instead.

@claude
Copy link

claude bot commented Mar 12, 2026

DryRun = true default on both specs is a safe default.

@claude
Copy link

claude bot commented Mar 12, 2026

PR Review summary - see below for full review posted in separate comment.

@claude
Copy link

claude bot commented Mar 12, 2026

test

@claude
Copy link

claude bot commented Mar 12, 2026

Merge bbf70fc into 145dcfd

1 similar comment
@claude
Copy link

claude bot commented Mar 12, 2026

Merge bbf70fc into 145dcfd

@claude
Copy link

claude bot commented Mar 12, 2026

MERGE BBF70FCADAD5A2FC28FD3749685D35BAF81D3E4E INTO 145DCFD7A2C789A71746C37B2EC653365BE9DE19

@SamErde
Copy link
Contributor

SamErde commented Mar 12, 2026

I'm very curious about your workflow here, @PrzemyslawKlys! :) Is Claude Code Review going back and creating release notes for all of your projects?

@claude
Copy link

claude bot commented Mar 12, 2026

@claude
Copy link

claude bot commented Mar 12, 2026

Code Review Test

Test body.

@claude
Copy link

claude bot commented Mar 12, 2026

Code Review: Add reusable GitHub housekeeping workflows

Overall this is a well-structured PR. The layering is clean (models → shared service → CLI command → composite action → reusable workflow), dry-run defaults are safe, the fake-HTTP test harness is solid, and rate-limit detection is a nice touch. Below are the issues I found, ordered by severity.


Bug

GitHubHousekeepingService.cs line 88 — "artifacts" added to failed without checking spec.Artifacts.Enabled

When only caches are enabled and the repository is missing, "artifacts" still lands in FailedSections even though it was never in RequestedSections. The downstream guard at line 108 prevents the service from actually running artifact cleanup, but FailedSections will contain "artifacts" and the PowerShell wrapper will report a failed section that was never requested.

The root fix is to gate both additions on their respective Enabled flags:

if (spec.Artifacts.Enabled) failed.Add("artifacts");
if (spec.Caches.Enabled)    failed.Add("caches");

None of the existing tests cover this path (caches-only + missing repo), which is why it slipped through.


Security / Correctness

GitHubActionsCacheCleanupService.cs line 359 — user-supplied regex without a timeout (ReDoS risk)

The expression from the config file re: prefix patterns is passed directly to Regex.IsMatch with no timeout. A catastrophically backtracking pattern could stall the process indefinitely. On .NET 7+ you can add RegexOptions.NonBacktracking; otherwise wrap the call with a Regex instance that has a matchTimeout of a few seconds.

Invoke-PowerForgeHousekeeping.ps1 lines 125-127 — token passed as a CLI argument

Passing the token via --token <value> makes it visible in process listings (/proc/<pid>/cmdline, ps aux) for the duration of the dotnet call. The service already reads the token from an environment variable when using the config-driven path (ParseGitHubHousekeepingArgs), so the env var could be forwarded directly instead of via --token. At minimum, the README and action description should warn consumers to use a scoped, short-lived token.


Design

RunnerHousekeepingService.cs line 431 ℔ FormatGiB returns an integer

bytes / 1024L / 1024L / 1024L silently truncates. 1.9 GiB shows as "1 GiB" in logs, which is misleading when diagnosing tight free-space conditions. The PowerShell helper Format-GiB in the companion script uses floating-point ({0:N1} GiB). The C# method should either return a double or be renamed ToGiBWhole to make the truncation intentional.

Invoke-PowerForgeHousekeeping.ps1 line 132 — stderr merged into stdout

2>&1 merges stderr into the string that ConvertFrom-Json must parse. If dotnet emits any diagnostics to stderr, the JSON parse fails and the try/catch re-throws after printing raw output. In practice --no-build suppresses most dotnet noise, but separating stderr from stdout would make this more robust and easier to diagnose.

RunnerHousekeepingService.cs lines 340-357 — delete failures abort the whole step

When allowSudo is false and a file/directory deletion raises an access-denied exception, the exception propagates uncaught through DeleteTargets and aborts the entire housekeeping run. There is no per-target error aggregation. For a cleanup service operating on a shared runner, partial success is often better than a hard abort.


Workflow notes

github-housekeeping.yml line 26 — scheduled runs always apply

The operator precedence in the apply: expression is correct (this is not a bug), so scheduled triggers always apply deletions. This is intentional but could surprise adopters who expect scheduled runs to default to dry-run. A comment clarifying the intent would help.

reusable-github-housekeeping.yml — recommend pinning powerforge-ref to a commit SHA

Calling repos that use the default "main" ref pick up changes automatically. The README example should recommend pinning to a commit SHA for production use to avoid silent breakage from future changes to this repo.


Test coverage gaps

Scenarios not currently covered:

  1. Housekeeping with caches-only and missing repository (reveals the bug above)
  2. Runner-only mode with no remote identity needed
  3. The re: prefix regex pattern matching
  4. Runner delete failure propagation

Positive notes

  • Safe dry-run default everywhere (DryRun = true on the spec, "true" default on workflow inputs).
  • SocketsHttpHandler with PooledConnectionLifetime is the right choice for the shared HttpClient.
  • Rate-limit hint in error messages (X-RateLimit-Remaining / X-RateLimit-Reset) is a nice debugging touch.
  • concurrency.cancel-in-progress: false correctly prevents overlapping cleanup runs.
  • The re: glob/regex dual-mode pattern matching is a clean, user-friendly extension point.
  • Comprehensive XML doc comments on all public types.

Summary: the artifact-in-failed-sections bug should be fixed before merge. The regex timeout and token-in-args concerns are worth addressing soon for production hardening. The rest are quality improvements that can be tracked separately.

@claude
Copy link

claude bot commented Mar 12, 2026

Code Review

Overall this is a well-structured PR. The refactoring in Program.Command.GitHub.cs is a clear improvement, the new C# services are well-designed with proper DI patterns, and the test coverage for the new services is solid. A few issues worth addressing:


Security

Build/project.build.json — hardcoded local file paths committed to a public repo

"PublishApiKeyFilePath": "C:\\Support\\Important\\NugetOrgEvotec.txt",
"GitHubAccessTokenFilePath": "C:\\Support\\Important\\GithubAPI.txt",
"CertificateThumbprint": "483292C9E317AA13B07BB7A96AE9D1A5ED9E7703",

The file paths are developer-specific and won't work in CI. The certificate thumbprint is also now public. Consider either removing these keys from the committed config and relying on CI environment variables/secrets, or adding a .gitignore entry and using a separate project.build.local.json for machine-specific overrides.

GitHub Actions not pinned to commit SHAs

actions/checkout@v4 and actions/setup-dotnet@v4 are used unpinned in both the composite action and the reusable workflow. These workflows have actions: write permission and handle the GITHUB_TOKEN. For a public repo, pinning to a full commit SHA is a supply-chain best practice:

# instead of
uses: actions/checkout@v4
# prefer
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

powerforge-ref defaults to "main" in the reusable workflow

Callers who omit this parameter will always track the tip of main. Consider defaulting to the current stable tag/SHA instead, or at minimum document that callers should pin this.


Bug / Behavioral Change

ClearOut implicit default removed (Build/Build-PowerForge.ps1)

The original code had:

if ($UseStaging -and -not $PSBoundParameters.ContainsKey('ClearOut')) {
    $ClearOut = $true
}

This block was removed. Now ClearOut defaults to $false even when UseStaging = $true. If any callers relied on staging implicitly clearing the output directory, they'll now get stale artifacts mixed in. If this is intentional, it's worth a comment or changelog note.


Code Quality

Publish-GitHubAssets captures script-scope $selectedTools implicitly

function Publish-GitHubAssets {
    ...
    $tagName = if (...$selectedTools.Count -eq 1) {   # $selectedTools is a script-level var

PowerShell's dynamic scoping makes this work, but it's fragile — if the function is ever extracted or called from a different scope the reference will silently break. Pass $selectedTools as a parameter or use $script:selectedTools to make the intent explicit.

Invoke-PowerForgeHousekeeping.ps12>&1 merges stderr into the JSON parse target

$rawOutput = (& dotnet $arguments 2>&1 | Out-String).Trim()
...
$envelope = $rawOutput | ConvertFrom-Json -Depth 30

If dotnet writes anything to stderr (e.g. a first-run message, a .NET SDK warning, a missing global.json notice), ConvertFrom-Json will throw and the catch block will re-throw with the raw merged output as context. DOTNET_NOLOGO and DOTNET_CLI_TELEMETRY_OPTOUT help, but they don't cover all cases. Consider redirecting stderr separately:

$rawOutput = (& dotnet $arguments 2>$null | Out-String).Trim()
# or capture to a temp variable and log it only on failure

Minor / Nice-to-have

github-housekeeping.yml — complex apply expression

apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply == 'true' || github.event_name != 'workflow_dispatch' }}

Logic is correct but non-obvious. A brief inline comment would help:

# scheduled runs always apply; manual dispatch respects the input
apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply == 'true' || github.event_name != 'workflow_dispatch' }}

IncludeAllContentForSelfExtract fix is a genuine bug fix — the original code incorrectly used $singleFile where $selfExtract is semantically correct. Good catch.

Zip naming — switching from a timestamp suffix to $version-$Framework-$rid-$Flavor is a clear improvement for reproducibility.


Positives

  • The extraction of WriteGitHubCommandArgumentError / WriteGitHubCommandFailure helpers removes substantial duplication from Program.Command.GitHub.cs.
  • GitHubHousekeepingService uses constructor injection for sub-services, making it fully testable without real HTTP calls.
  • RunnerHousekeepingService correctly gates aggressive operations (docker prune, tool-cache wipe) behind both the aggressiveApplied flag and individual skip flags.
  • Build-PowerForgeWeb.ps1 as a thin wrapper around Build-PowerForge.ps1 with Tool = 'PowerForgeWeb' avoids duplication cleanly.
  • Test coverage for the new services is good — dry-run path, apply path, and aggressive mode are all covered.

@claude
Copy link

claude bot commented Mar 13, 2026

Code Review: Add reusable GitHub housekeeping workflows

Overall this is a well-structured PR that adds a useful, config-driven housekeeping workflow. The service layer has good separation of concerns, dry-run support is well integrated throughout, and the test files cover the main happy paths. Below are my findings, organised by priority.


🔴 Issues to address

1. Token exposed in process argument list (Invoke-PowerForgeHousekeeping.ps1 ~L131)

$null = $arguments.Add('--token')
$null = $arguments.Add($env:POWERFORGE_GITHUB_TOKEN)

Passing the GitHub token as a CLI argument makes it visible in ps aux, /proc/<pid>/cmdline, and shell history. The token should be passed via an environment variable instead, leveraging the existing --token-env flag:

# Replace the --token block with:
$null = $arguments.Add('--token-env')
$null = $arguments.Add('POWERFORGE_GITHUB_TOKEN')
# POWERFORGE_GITHUB_TOKEN is already set in the step env

2. Sync-over-async (GitHubActionsCacheCleanupService.cs ~L4632)

using var response = _client.SendAsync(request).ConfigureAwait(false).GetAwaiter().GetResult();
var body = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult();

This pattern appears in several HTTP helper methods. Blocking the thread pool with .GetAwaiter().GetResult() can cause deadlocks under certain synchronisation contexts and thread-pool starvation under load. Since the CLI entry point already supports async, the service methods should be async Task<T> with proper await.

3. sudo rm -rf without target validation (RunnerHousekeepingService.cs ~L6741)

var result = RunProcess("sudo", new[] { "rm", "-rf", target }, ...);

target is derived from environment variables and user config. Before calling sudo rm -rf, there should be an explicit allowlist check that the resolved path is under expected runner root directories (e.g. RUNNER_TEMP, RUNNER_TOOL_CACHE). This prevents accidental or malicious cleanup of system paths. A path-prefix validation against known-safe base directories would address this.


🟡 Worth discussing

4. GitHub workflow apply expression passes a boolean where a string is expected (github-housekeeping.yml L25)

apply: ${{ github.event_name == 'workflow_dispatch' && inputs.apply == 'true' || github.event_name != 'workflow_dispatch' }}

This evaluates to the boolean true/false in GitHub Expressions, but reusable-github-housekeeping.yml declares apply as type: boolean. The reusable workflow then does:

apply: ${{ inputs.apply && 'true' || 'false' }}

The double conversion (bool → bool → string) is fragile. Consider simplifying by keeping apply as type: string in both places, or ensuring the caller directly passes 'true'/'false' strings.

5. No build caching in composite action (action.yml)

The Build PowerForge CLI step runs a full dotnet build on every workflow execution with no NuGet or build-output caching. For a frequently-scheduled workflow (every 6 hours) this is slow and wasteful. Adding actions/cache keyed on the lock file hash would significantly speed up runs.

- uses: actions/cache@v4
  with:
    path: ~/.nuget/packages
    key: nuget-${{ hashFiles('**/packages.lock.json') }}

6. Pagination loop has no hard upper bound (GitHubActionsCacheCleanupService.cs)

The ListCaches method paginates using totalCount as an upper bound. If the GitHub API response ever returns a totalCount larger than the actual number of items (e.g. due to concurrent deletions mid-pagination), the loop could over-fetch. A max-pages safety limit (e.g. 50 pages) would prevent runaway API calls.

7. Missing token sanitisation in error log output

When HTTP calls fail, the raw response body is logged. While no token string is logged directly, the Authorization header is included in the HttpRequestMessage. Confirm that error logging does not capture request headers — if it does, consider redacting them.


✅ Positive observations

  • Dry-run mode is thoroughly integrated and defaults to safe behaviour.
  • NormalizeSpec() pattern provides a clean validation layer before service execution.
  • JSON schema files (github.housekeeping.schema.json) are a nice addition for config validation.
  • Composite action design (build once, run script) is clean and the helper PowerShell script is well-structured.
  • Test coverage for GitHubActionsCacheCleanupServiceTests and RunnerHousekeepingServiceTests covers the main scenarios well.
  • concurrency: cancel-in-progress: false on the scheduled workflow is the right choice for a destructive cleanup job.

Minor nits

  • Invoke-PowerForgeHousekeeping.ps1 L99: $lines += '' appears multiple times to add blank lines before sections; extracting this into a helper would tidy things up.
  • reusable-github-housekeeping.yml L47: the checkout path .powerforge/pspublishmodule shadows the .powerforge/ config directory — this is fine functionally but worth a comment explaining the layout to avoid confusion for future contributors.
  • README.md: The "Direct action usage" example is missing apply: "false" showing how to do a dry-run preview, which is the recommended first use.

Summary: The core issue to fix before merging is the token exposure in the process argument list (#1). The sync-over-async pattern (#2) and sudo path validation (#3) should also be addressed for production robustness.

@claude
Copy link

claude bot commented Mar 13, 2026

Code Review

Overall this is a well-structured PR that meaningfully consolidates package and tool release workflows into a unified entrypoint. The security fix for the sudo delete path traversal is especially appreciated. A few issues worth addressing before merge:


🔴 Issues

1. Potential deadlock in RunProcess (PowerForgeToolReleaseService.cs)

The synchronous implementation reads stdout to completion before reading stderr:

var stdOut = process.StandardOutput.ReadToEnd();
var stdErr = process.StandardError.ReadToEnd();
process.WaitForExit();

If dotnet publish fills the stderr pipe buffer while ReadToEnd() is blocking on stdout, the process will deadlock waiting for stderr to be drained. This is a well-known issue documented in the Process class. Fix: use ReadToEndAsync on both streams concurrently, or read one of them on a background thread. The existing RunProcess helper in RunnerHousekeepingService does not have this issue because it redirects both streams before starting.

2. $args shadows a PowerShell automatic variable (Build-Project.ps1)

$args = @('run', '--project', $project, ...)
dotnet @args

$args is an automatic variable in PowerShell (holds unbound positional arguments). Assigning to it in script scope technically works but is confusing, will trigger PSScriptAnalyzer warnings, and can cause unexpected behavior inside functions. Rename to $dotnetArgs or $dotnetPublishArgs.


🟡 Potential Issues

3. Hardcoded developer-local paths in committed release.json

"PublishApiKeyFilePath": "C:\\Support\\Important\\NugetOrgEvotec.txt",
"GitHubAccessTokenFilePath": "C:\\Support\\Important\\GithubAPI.txt",
"TokenFilePath": "C:\\Support\\Important\\GithubAPI.txt"

These paths are specific to one developer's machine and will silently fail for anyone else. If the intent is "each developer fills in their own path," consider adding a comment in the JSON or a companion release.local.json.example, and add release.local.json to .gitignore. Alternatively, prefer TokenEnvName for CI use.

4. Race condition in ApplyGitHubTemplate (PowerForgeReleaseService.cs)

var now = DateTime.Now;
var utcNow = DateTime.UtcNow;

These two calls happen on separate lines. At a second boundary, {Date} and {UtcDate} tokens in the same template expansion could reflect different timestamps. Capture both at the start of Execute and pass them in, or snapshot a single DateTimeOffset.UtcNow at the top of the service call.

5. PlanOnly = request.PlanOnly || request.ValidateOnly ? true : null (PowerForgeReleaseService.cs)

This assigns null (not false) when neither flag is set. While the downstream consumer likely treats null as "use the config default," the pattern is easy to misread. A comment explaining the three-state intent or using a named helper would aid readability.

6. Config discovery includes overly-broad "release.json" candidate

In FindDefaultReleaseConfig, the last candidate is the bare filename "release.json", which is a very common name. Walking parent directories with this fallback could accidentally match an unrelated file. Consider making the bare-name fallback opt-in (e.g., only search the current directory for release.json, not parents).


🟢 Suggestions (non-blocking)

7. Simplify redundant ternary expressions (Program.Command.Release.cs)

// Current
PublishNuget = argv.Any(a => ...) ? true : null,

true : null is valid but unconventional for a bool?. Consider:

PublishNuget = argv.Any(a => ...) ? (bool?)true : null,

or extract a local bool publishNuget = argv.Any(...) and then PublishNuget = publishNuget ? true : null. Minor style point.

8. Silent exception swallowing in ApplyCleanup

try { File.Delete(file); } catch { }

Best-effort cleanup is reasonable, but consider at least logging at verbose level when a file cannot be deleted. This would help diagnose cases where symbol files remain unexpectedly.

9. Reflection-based test for private method is fragile

GuardedSudoDelete_RejectsTargetsOutsideAllowedRoot invokes EnsureDeleteTargetWithinRoot via reflection. If the method is renamed or made non-static, the test silently degrades to Assert.NotNull(method) failing. Consider making EnsureDeleteTargetWithinRoot internal and using [InternalsVisibleTo], or extracting it to a testable static helper class.


✅ Positives

  • The --token--token-env change in Invoke-PowerForgeHousekeeping.ps1 is a solid security improvement; the token is no longer exposed in process argument lists.
  • The EnsureDeleteTargetWithinRoot guard for sudo deletes is a meaningful hardening of the runner housekeeping path.
  • The async refactor of GitHubActionsCacheCleanupService is clean and consistent.
  • Dependency injection via constructor-injected Func<> delegates makes both services testable without mocking frameworks — good design.
  • Schema and JSON context registrations are all in order for AOT compatibility.

@claude
Copy link

claude bot commented Mar 13, 2026

PR Review: Add reusable GitHub housekeeping workflows / unified release entrypoint

Overall this is a well-structured PR that consolidates a lot of duplicated build logic into a unified service layer. The architecture is clean and testable. A few issues worth addressing before merge:


🔴 Security / Breaking Issues

1. Build/release.json commits developer-local Windows paths

The file includes hardcoded developer machine paths:

"PublishApiKeyFilePath": "C:\\Support\\Important\\NugetOrgEvotec.txt",
"GitHubAccessTokenFilePath": "C:\\Support\\Important\\GithubAPI.txt",
"TokenFilePath": "C:\\Support\\Important\\GithubAPI.txt"

These will fail for any other contributor or CI/CD runner, and PublishNuget/PublishGitHub are false so this doesn't execute today — but the file is now the stated "source of truth". Consider substituting with environment variable references (e.g. TokenEnvName) or replacing the file paths with a comment or placeholder, and document that developers must configure their own token paths locally.

2. EnsureDeleteTargetWithinRoot uses OrdinalIgnoreCase on Linux

if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))

On Linux/macOS, the filesystem is case-sensitive. OrdinalIgnoreCase could theoretically allow a bypass in edge cases (e.g. symlinks that resolve differently by case). Consider Ordinal on non-Windows, or use Path.GetRelativePath which handles this more robustly:

var rel = Path.GetRelativePath(root, fullTarget);
if (rel.StartsWith("..", StringComparison.Ordinal))
    throw ...;

🟡 Code Quality

3. Build/Build-Project.ps1 and Build-PowerForgeWeb.ps1 lost error safety guards

The old scripts both had:

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

These were removed in the new versions. Without them, PowerShell errors may silently continue. Both new script bodies are short wrappers, but it's still a regression in robustness.

4. ExecutePublish uses psi.Arguments (string) instead of psi.ArgumentList

psi.Arguments = string.Join(" ", args);

This requires manual quoting (the Quote() helper) and is fragile with paths that contain spaces, quotes, or shell metacharacters. The safer approach is psi.ArgumentList.Add(...) which passes each argument verbatim to the OS without shell parsing. The Quote() helper and string-join can then be removed entirely.

5. WriteManifest bypasses source-generated JSON serialization

var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });

The rest of the codebase uses the AOT-safe CliJson.Context. This will break in trimmed/AOT builds and is inconsistent. Consider adding PowerForgeToolReleaseManifest to the JSON context and using CliJson.Context.PowerForgeToolReleaseManifest.

6. RunProcess in PowerForgeToolReleaseService has no timeout

process.WaitForExit();

If dotnet publish hangs (network issue, deadlock on stdout/stderr buffers), this will block forever. Consider a generous timeout (e.g. 10–30 minutes) and throw a descriptive exception on timeout:

if (!process.WaitForExit(TimeSpan.FromMinutes(20)))
{
    process.Kill(entireProcessTree: true);
    throw new TimeoutException($"dotnet publish timed out for ...");
}

Note: ReadToEndAsync() calls should complete after WaitForExit() since the streams are closed, so the existing pattern is safe from the stdout/stderr deadlock — but a timeout on the process itself is still worth adding.

7. ApplyGitHubTemplate mixes local and UTC time inconsistently

var now = DateTime.Now;       // local
var utcNow = DateTime.UtcNow; // UTC

{Date} uses local time and {UtcDate} uses UTC — that's intentional by the naming. However, {DateTime} / {Timestamp} also use local time without the naming making this obvious. Consider either naming them {LocalDate} / {UtcDate} or just using UTC throughout for release automation.

8. FindDefaultReleaseConfig silently swallows all exceptions

catch { /* ignore */ }

This swallows things like OutOfMemoryException, StackOverflowException (in theory), etc. Narrowing to IOException | UnauthorizedAccessException would be more appropriate here.


🟢 Positive Observations

  • Token security improvement in Invoke-PowerForgeHousekeeping.ps1: switching from --token <value> to --token-env <name> is a clear win; token values no longer appear in process argument lists.
  • Async refactoring in GitHubActionsCacheCleanupService: eliminating all the GetAwaiter().GetResult() calls inside the service is the right direction. The public sync wrapper is a reasonable compatibility shim.
  • EnsureDeleteTargetWithinRoot / AppendDirectorySeparator: the path-escape guard for sudo rm -rf is a meaningful security addition — good defensive programming.
  • Plan / Run separation in PowerForgeToolReleaseService: clean design that enables dry-run, progress reporting, and good testability.
  • Dependency injection via delegates in PowerForgeReleaseService: makes the service fully unit-testable without I/O, as demonstrated by the new test suite.
  • Test coverage: the three new test cases (ToolReleasePlan_AppliesOverridesAcrossSelectedTarget, Execute_GroupsToolAssetsIntoSingleGitHubReleasePerTarget, GuardedSudoDelete_RejectsTargetsOutsideAllowedRoot) cover the most important new behaviors.

Minor / Nit

  • ToolReleaseRunProcess_CapturesStdOutAndStdErrWithoutBlocking uses cmd.exe and a .cmd script — it will be skipped (or fail) on Linux/macOS CI. Consider guarding with [SkipUnlessOsCondition] / [PlatformSpecific] or replacing with a cross-platform equivalent (e.g. sh -c 'echo stdout-line; echo stderr-line >&2').
  • PowerForgeReleaseRequest.cs: PublishNuget = argv.Any(...) ? true : null — the ternary is correct but slightly unusual; argv.Any(...) ? (bool?)true : null or a simple helper would clarify intent.

@PrzemyslawKlys PrzemyslawKlys merged commit 15f79d0 into main Mar 13, 2026
6 checks passed
@PrzemyslawKlys PrzemyslawKlys deleted the codex/github-housekeeping branch March 13, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants