From 040f8dab7ba6d484aba91c4ae78c57a9f3cf72b8 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Thu, 8 Oct 2026 20:39:54 +0200 Subject: [PATCH 1/3] Share the action helpers in a Rulebook.Action module The four entry scripts under actions/ each carried their own copies of the annotation escaping, Add-Annotation, Add-Failure, Write-Text and the path resolver, and Format-TableCell and Limit-SummaryText lived in Rulebook.Update although Validate and Scan need them as well. ChangeRule (#11) would have made a fifth copy. modules/Rulebook.Action is a leaf module without engine imports that exports ten helpers: Format-AnnotationText, ConvertTo-SingleLine, Format-TableCell, New-ActionContext (annotations, error messages, the first failure kind), Add-Annotation, Add-Failure, Write-Text, Resolve-ActionPath, Write-ActionOutput and Limit-SummaryText. Format-TableCell and Limit-SummaryText move here from Rulebook.Update, which drops them from its exports; Update and Scan import Rulebook.Action. Validate, Publish, CheckForUpdates and ScanDiagnostics import it and keep their emitted lines, summaries and outputs byte for byte, so the four action suites pass with only Rulebook.Action added to their AfterAll lists. Validate now caps its summary with Limit-SummaryText (cut at a line boundary) instead of the inline character cut, and its Get-Command guard goes. The Limit-SummaryText tests move to the new tests/Rulebook.Action.Tests.ps1. Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 4 +- README.md | 2 +- actions/CheckForUpdates/CheckForUpdates.ps1 | 84 ++------ actions/Publish/Publish.ps1 | 90 +++----- actions/ScanDiagnostics/ScanDiagnostics.ps1 | 85 +++----- actions/Validate/Validate.ps1 | 61 ++---- docs/ARCHITECTURE.md | 2 +- modules/Rulebook.Action.psd1 | 33 +++ modules/Rulebook.Action.psm1 | 202 ++++++++++++++++++ modules/Rulebook.Scan.psm1 | 1 + modules/Rulebook.Update.psd1 | 2 - modules/Rulebook.Update.psm1 | 51 +---- tests/CheckForUpdates.Action.Tests.ps1 | 2 +- tests/Publish.Action.Tests.ps1 | 2 +- tests/Rulebook.Action.Tests.ps1 | 216 ++++++++++++++++++++ tests/Rulebook.Scan.Tests.ps1 | 2 +- tests/Rulebook.Update.Tests.ps1 | 12 +- tests/ScanDiagnostics.Action.Tests.ps1 | 2 +- tests/Validate.Action.Tests.ps1 | 2 +- 19 files changed, 547 insertions(+), 308 deletions(-) create mode 100644 modules/Rulebook.Action.psd1 create mode 100644 modules/Rulebook.Action.psm1 create mode 100644 tests/Rulebook.Action.Tests.ps1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e3ac2d..53a97de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,14 +29,14 @@ What exists today, and the work package that adds the rest. A folder is created | `.github/ISSUE_TEMPLATE/` | Issue forms: work package, task or spin-off. | written | | `PSScriptAnalyzerSettings.psd1` | Analyzer settings: errors and warnings, default rules, justified exclusions only. | WP00 | | `schemas/` | JSON schemas for every file in an organization rulebook repository, draft 2020-12, no `$id`; names and URLs in [docs/reference/naming.md](docs/reference/naming.md). | WP02 ([#4](https://github.com/ALCops/rulebook-engine/issues/4)) | -| `tests/` | `Smoke.Tests.ps1`, `Schemas.Tests.ps1`, `Rulebook.Generate.Tests.ps1`, `Rulebook.Validate.Tests.ps1`, `Validate.Action.Tests.ps1`, `Rulebook.Template.Tests.ps1`, `Rulebook.Publish.Tests.ps1`, `Publish.Action.Tests.ps1`, `Get-RulebookSkeletons.Tests.ps1`, `Rulebook.GitHub.Tests.ps1`, `Rulebook.Update.Tests.ps1`, `CheckForUpdates.Action.Tests.ps1`, `Rulebook.NuGet.Tests.ps1`, `Rulebook.Extract.Tests.ps1`, `Rulebook.Catalog.Tests.ps1`, `Rulebook.Quarantine.Tests.ps1`, `Rulebook.Scan.Tests.ps1` and `ScanDiagnostics.Action.Tests.ps1` now; later one suite per module and action, with fixtures under `tests/fixtures/`. | WP00, WP02, WP03, WP04, WP05, WP06, WP07, WP08; WP12 ([#14](https://github.com/ALCops/rulebook-engine/issues/14)) and every module work package | +| `tests/` | `Smoke.Tests.ps1`, `Schemas.Tests.ps1`, `Rulebook.Generate.Tests.ps1`, `Rulebook.Validate.Tests.ps1`, `Validate.Action.Tests.ps1`, `Rulebook.Template.Tests.ps1`, `Rulebook.Publish.Tests.ps1`, `Publish.Action.Tests.ps1`, `Get-RulebookSkeletons.Tests.ps1`, `Rulebook.GitHub.Tests.ps1`, `Rulebook.Update.Tests.ps1`, `CheckForUpdates.Action.Tests.ps1`, `Rulebook.NuGet.Tests.ps1`, `Rulebook.Extract.Tests.ps1`, `Rulebook.Catalog.Tests.ps1`, `Rulebook.Quarantine.Tests.ps1`, `Rulebook.Scan.Tests.ps1`, `ScanDiagnostics.Action.Tests.ps1` and `Rulebook.Action.Tests.ps1` now; later one suite per module and action, with fixtures under `tests/fixtures/`. | WP00, WP02, WP03, WP04, WP05, WP06, WP07, WP08; WP12 ([#14](https://github.com/ALCops/rulebook-engine/issues/14)) and every module work package | | `tests/fixtures/schemas/` | One file per case: `//.json`, each invalid file a one-change mutation of a valid one. | WP02 | | `tests/fixtures/repos/` | Organization rulebook repositories for the module suites. `valid-minimal` (30-id catalog, four levels, three stages), `stale-endpoints` and `update-org` (WP07) are complete on disk, their `rulesets/` written by `Update-RulebookEndpoints`; every other folder is an overlay holding only the files it changes, copied over `valid-minimal` by `New-FixtureRepo`. | WP03 | | `tests/fixtures/templates/` | `v1` and `v2`: two versions of a mini template for the update suites, with the exact difference list and the derivation of `repos/update-org` in its README. | WP07 | | `tests/fixtures/stub-analyzers/` | C# stubs of the compiler and the twelve cop assemblies and `Build-StubPackage.ps1`, which compiles them with the Roslyn of pwsh into fixture nupkgs (variants tools 18.0.43.1464 and 30.0.42.60748-beta, ALCops 1.3.1, 1.4.0-beta.1 and 1.4.0) for the scan suites; `tests/Helpers/StubFeed.ps1` builds them once per source hash. | WP08 | | `tests/fixtures/matrix/` | `tiny`: a six-id level content in the layout of `docs/rulebook/` (inventory, matrix, resolved cells, levels, stages, twins) for the unit cases of the Template suite. | WP04 | | `tests/Helpers/` | Helpers the suites dot-source in `BeforeAll`: `RepoFixture.ps1` copies a fixture into `TestDrive`, edits its JSON, creates git repositories, bare origins with a push-refusing hook and the synthetic performance rulebook; `StubFeed.ps1` builds NuGet flat containers of the stub analyzer packages. | WP03, WP08 | -| `modules/` | PowerShell modules shared by the actions, each a `.psm1` with a `.psd1` manifest: `Rulebook.Generate`, `Rulebook.Validate`, `Rulebook.Template`, `Rulebook.Publish`, `Rulebook.GitHub`, `Rulebook.Update`, `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine` and `Rulebook.Scan` (written). | WP03 to WP09 | +| `modules/` | PowerShell modules shared by the actions, each a `.psm1` with a `.psd1` manifest: `Rulebook.Generate`, `Rulebook.Validate`, `Rulebook.Template`, `Rulebook.Publish`, `Rulebook.GitHub`, `Rulebook.Update`, `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine`, `Rulebook.Scan` and `Rulebook.Action` (written). | WP03 to WP09 | | `actions/` | Composite actions, one folder each with `action.yaml` and its entry script. | WP03 to WP09 | | `actions/Validate/` | `action.yaml` and `Validate.ps1`: checks C1 to C16, annotations, the job summary with the effective diff ([ARCHITECTURE.md](docs/ARCHITECTURE.md) section 5.5). | WP03 | | `actions/Publish/` | `action.yaml` and `Publish.ps1`: stage the endpoints, the rendered skeletons and `index.html`, the Pages preflight, deploy to GitHub Pages, the reachability check ([ARCHITECTURE.md](docs/ARCHITECTURE.md) section 7.2, [docs/reference/publish-targets.md](docs/reference/publish-targets.md)). | WP05 | diff --git a/README.md b/README.md index 10772fe..2d46f2a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The engine behind [ALCops/rulebook](https://github.com/ALCops/rulebook): the com | Path | Content | Status | |---|---|---| | `actions//action.yaml` | Composite actions, each running a PowerShell 7 entry script on `ubuntu-latest`: `Validate` (checks, effective diff, update check), `Publish` (gate, stage, deploy, verify), `CheckForUpdates` (update from the template), `ScanDiagnostics` (the daily diagnostic scan). | `Validate` (WP03), `Publish` (WP05), `CheckForUpdates` (WP07) and `ScanDiagnostics` (WP08) written; `ChangeRule` planned (WP09) | -| `modules/Rulebook.*.psm1` | PowerShell modules shared by the actions, each with a `.psd1` manifest: `Rulebook.Generate` (level chain + stage deltas + twins setting + overrides + quarantine to sparse flat endpoints, effective diff), `Rulebook.Validate` (checks C1 to C16), `Rulebook.Template` (level, stage, twins, catalog and skeleton files of `template/` from `docs/rulebook/`), `Rulebook.Publish` (staging, Pages deploy, reachability), `Rulebook.Update` and `Rulebook.GitHub` (the update and the GitHub plumbing), and for the scan `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine` and `Rulebook.Scan`. | written (WP03 to WP08) | +| `modules/Rulebook.*.psm1` | PowerShell modules shared by the actions, each with a `.psd1` manifest: `Rulebook.Generate` (level chain + stage deltas + twins setting + overrides + quarantine to sparse flat endpoints, effective diff), `Rulebook.Validate` (checks C1 to C16), `Rulebook.Template` (level, stage, twins, catalog and skeleton files of `template/` from `docs/rulebook/`), `Rulebook.Publish` (staging, Pages deploy, reachability), `Rulebook.Update` and `Rulebook.GitHub` (the update and the GitHub plumbing), for the scan `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine` and `Rulebook.Scan`, and `Rulebook.Action` (the helpers every action entry script shares: annotations, the failure kind, the job summary and `GITHUB_OUTPUT`). | written (WP03 to WP09) | | `template/` | Source of the template content that a deploy workflow copies into `ALCops/rulebook`, pinning action references from `@main` to `@v1`. Its `base/`, `stages/` and `rulesets/` are generated from `docs/rulebook/`. | written (WP03 to WP08), 43 files: settings, the workflows `Validate.yaml`, `Publish.yaml`, `UpdateRulebookSystemFiles.yaml` and `ScanDiagnostics.yaml`, the README files, empty overrides and quarantine files, and the 32 generated files; the other workflows come with their work packages, the deploy with WP13 | | `schemas/` | JSON schemas for every file in an organization rulebook repository (ruleset profiles, overrides, quarantine, twins, catalog, scan state, settings), served from the `v1` branch over raw URLs, which go live with WP13 ([#15](https://github.com/ALCops/rulebook-engine/issues/15)) and return 404 until then; the tests use the local files. See [docs/reference/naming.md](docs/reference/naming.md). | written (WP02) | | `tests/` | Pester 6 suites, one per module and action, with fixtures under `tests/fixtures/`: the schema suite and its fixtures under `tests/fixtures/schemas/`; the Generate, Validate and Validate action suites with organization rulebook fixtures under `tests/fixtures/repos/` and helpers in `tests/Helpers/`. | a suite per module and action (WP00 to WP08); the scan suites build stub analyzer packages from `tests/fixtures/stub-analyzers/` at test time | diff --git a/actions/CheckForUpdates/CheckForUpdates.ps1 b/actions/CheckForUpdates/CheckForUpdates.ps1 index ecb31a7..e37a664 100644 --- a/actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/actions/CheckForUpdates/CheckForUpdates.ps1 @@ -52,57 +52,19 @@ $modules = Join-Path $PSScriptRoot '..' '..' 'modules' Import-Module (Join-Path $modules 'Rulebook.Generate.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.GitHub.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.Update.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.Action.psd1') -Force $docsUrl = 'https://github.com/ALCops/rulebook/blob/main/docs/ghtokenworkflow.md' -function Format-AnnotationText { - # Workflow command escaping: the message part escapes %, CR and LF; a property value also : and ,. - param([AllowNull()][string]$Text, [switch]$Property) - if ($null -eq $Text) { return '' } - $escaped = $Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') - if ($Property) { $escaped = $escaped.Replace(':', '%3A').Replace(',', '%2C') } - return $escaped -} - -function ConvertTo-SingleLine { - # A message on one Markdown line (list item or paragraph); no table escaping. - param([AllowNull()][string]$Text) - if ([string]::IsNullOrEmpty($Text)) { return '' } - return $Text.Replace("`r", ' ').Replace("`n", ' ') -} - -function Add-Annotation { - param([ValidateSet('error', 'warning', 'notice')][string]$Command = 'error', [string]$File, [string]$Title = 'CheckForUpdates', [Parameter(Mandatory)][string]$Message) - $properties = "title=$(Format-AnnotationText $Title -Property)" - if ($File) { $properties = "file=$(Format-AnnotationText $File -Property),$properties" } - $line = "::$Command $properties::$(Format-AnnotationText $Message)" - $script:annotations.Add($line) - if ($Command -eq 'error') { $script:errorMessages.Add($Message) } - Write-Host $line -} - -function Write-Text { - param([AllowNull()][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Text) - if ([string]::IsNullOrEmpty($Path)) { return } - [System.IO.File]::AppendAllText($Path, $Text.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) -} - # [System.IO.File] resolves a relative path against the process directory, not the PowerShell location. -$resolvePath = { param($Path) if ([string]::IsNullOrEmpty($Path)) { $Path } else { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } -$SummaryPath = & $resolvePath $SummaryPath +$SummaryPath = Resolve-ActionPath $SummaryPath $ownWork = [string]::IsNullOrEmpty($WorkPath) if ($ownWork) { $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } $WorkPath = Join-Path $tempRoot ('rulebook-update-' + [guid]::NewGuid().ToString('n').Substring(0, 8)) } -$WorkPath = & $resolvePath $WorkPath -$script:annotations = [System.Collections.Generic.List[string]]::new() -$script:errorMessages = [System.Collections.Generic.List[string]]::new() -$script:failure = $null -function Add-Failure { - param([Parameter(Mandatory)][string]$Kind) - if ($null -eq $script:failure) { $script:failure = $Kind } -} +$WorkPath = Resolve-ActionPath $WorkPath +$ctx = New-ActionContext -Title 'CheckForUpdates' # Every token obtained by an exchange is masked before anything can print it. $maskToken = { param([string]$Value) if (-not [string]::IsNullOrEmpty($Value)) { Write-Host "::add-mask::$Value" } } @@ -133,11 +95,11 @@ try { # 1. Template URL and the token guard (before any request). $requested = if (-not [string]::IsNullOrWhiteSpace($TemplateUrl)) { $TemplateUrl } else { [string](& $setting 'templateUrl') } if ($Update -and -not [string]::IsNullOrEmpty($name) -and -not $nameValid) { - Add-Failure 'token' + Add-Failure -Context $ctx -Kind 'token' throw "ghTokenWorkflowSecretName '$name' in .github/Rulebook-Settings.json is not a valid secret name (letters, digits and underscores, not starting with a digit or GITHUB_). Read $docsUrl" } if ($Update -and [string]::IsNullOrWhiteSpace($Token)) { - Add-Failure 'token' + Add-Failure -Context $ctx -Kind 'token' throw "The $secretName secret is needed to update system files. Read $docsUrl" } @@ -149,7 +111,7 @@ try { try { $access = Get-GitHubAccessToken -Token $Token -Repository $Repository -ApiUrl $ApiUrl } catch { - Add-Failure 'token' + Add-Failure -Context $ctx -Kind 'token' throw "The $secretName secret could not be used: $($_.Exception.Message)" } if (-not [string]::IsNullOrEmpty($access.Token)) { & $maskToken $access.Token } @@ -179,10 +141,10 @@ try { $plan = Get-RulebookUpdatePlan -RepositoryRoot $root -Template $template -WorkPath $WorkPath } catch { if ($Update) { - if ($null -eq $template) { Add-Failure 'template' } + if ($null -eq $template) { Add-Failure -Context $ctx -Kind 'template' } throw } - Add-Annotation -Command warning -Message "update check skipped: $($_.Exception.Message)" + Add-Annotation -Context $ctx -Command warning -Message "update check skipped: $($_.Exception.Message)" $summaryMessage = "update check skipped: $($_.Exception.Message)" $plan = $null } @@ -194,7 +156,7 @@ try { $updatesAvailable = $plan.Valid -and $plan.UpdatesAvailable if (-not $Update) { - Add-Annotation -Command $status.Command -Message $status.Message + Add-Annotation -Context $ctx -Command $status.Command -Message $status.Message $summaryMessage = $status.Message } else { # 4. Update mode. @@ -202,9 +164,9 @@ try { foreach ($finding in @($plan.Findings | Where-Object Severity -EQ 'error')) { $message = if ($finding.Id) { "$($finding.Id): $($finding.Message)" } else { $finding.Message } $file = if ($finding.File) { [System.IO.Path]::GetRelativePath($workspace, (Join-Path $root $finding.File)).Replace($separator, '/') } else { $null } - Add-Annotation -File $file -Title $finding.Rule -Message "The updated rulebook would not validate: $message" + Add-Annotation -Context $ctx -File $file -Title $finding.Rule -Message "The updated rulebook would not validate: $message" } - Add-Failure 'validation' + Add-Failure -Context $ctx -Kind 'validation' $summaryMessage = 'The updated rulebook does not validate; nothing was pushed. The findings come from the repository after the update.' $failed = $true } else { @@ -218,26 +180,26 @@ try { } catch { $stage = [string]$_.Exception.Data['Stage'] if ($stage -cnotin 'push', 'pull-request') { $stage = 'push' } - Add-Failure $stage + Add-Failure -Context $ctx -Kind $stage $what = if ($stage -eq 'pull-request') { 'Failed to create the pull request for the Rulebook system files' } else { 'Failed to update the Rulebook system files' } throw "$what. Make sure that the token in the secret $secretName is not expired and may write contents, pull requests and workflows of $Repository. Read $docsUrl (Error was: $($_.Exception.Message))" } switch ($publish.Result) { 'exists' { - Add-Annotation -Command warning -Message "Pull request already exists: $($publish.PullRequestUrl)" + Add-Annotation -Context $ctx -Command warning -Message "Pull request already exists: $($publish.PullRequestUrl)" $summaryMessage = "Pull request already exists: $($publish.PullRequestUrl)" } 'no-changes' { - Add-Annotation -Command notice -Message 'No updates available' + Add-Annotation -Context $ctx -Command notice -Message 'No updates available' $summaryMessage = 'No updates available' } 'direct-commit' { - Add-Annotation -Command notice -Message "Rulebook system files updated in $($publish.Branch) ($($publish.Sha))" + Add-Annotation -Context $ctx -Command notice -Message "Rulebook system files updated in $($publish.Branch) ($($publish.Sha))" $summaryMessage = "Committed to $($publish.Branch)" } default { $suffix = if ($publish.Fallback) { ' (the direct commit was refused)' } else { '' } - Add-Annotation -Command notice -Message "Pull request: $($publish.PullRequestUrl)$suffix" + Add-Annotation -Context $ctx -Command notice -Message "Pull request: $($publish.PullRequestUrl)$suffix" $summaryMessage = "Pull request: $($publish.PullRequestUrl)$suffix" } } @@ -245,8 +207,8 @@ try { } } } catch { - Add-Annotation -Message $_.Exception.Message - Add-Failure 'error' + Add-Annotation -Context $ctx -Message $_.Exception.Message + Add-Failure -Context $ctx -Kind 'error' $summaryMessage = $_.Exception.Message $failed = $true } finally { @@ -270,18 +232,16 @@ $summary = Limit-SummaryText -Text $summary -MaxBytes $SummaryLimit -Footer "The Write-Text -Path $SummaryPath -Text $summary $pullRequestUrl = if ($null -ne $publish -and $publish.PullRequestUrl) { $publish.PullRequestUrl } else { '' } $sha = if ($null -ne $template) { $template.Sha } else { '' } -if ($env:GITHUB_OUTPUT) { - Write-Text -Path $env:GITHUB_OUTPUT -Text ("updatesAvailable={0}`npullRequestUrl={1}`ntemplateSha={2}`nfailure={3}`n" -f $updatesAvailable.ToString().ToLowerInvariant(), $pullRequestUrl, $sha, $script:failure) -} +Write-ActionOutput -Outputs ([ordered]@{ updatesAvailable = $updatesAvailable.ToString().ToLowerInvariant(); pullRequestUrl = $pullRequestUrl; templateSha = $sha; failure = $ctx.Failure }) [pscustomobject]@{ ExitCode = $(if ($failed) { 1 } else { 0 }) Mode = $mode - Failure = $script:failure + Failure = $ctx.Failure UpdatesAvailable = $updatesAvailable TemplateSha = $sha PullRequestUrl = $pullRequestUrl Plan = $plan Result = $publish - Annotations = $script:annotations.ToArray() + Annotations = $ctx.Annotations.ToArray() Summary = $summary } diff --git a/actions/Publish/Publish.ps1 b/actions/Publish/Publish.ps1 index 53a6737..01d7903 100644 --- a/actions/Publish/Publish.ps1 +++ b/actions/Publish/Publish.ps1 @@ -39,57 +39,19 @@ Set-StrictMode -Version 3.0 $modules = Join-Path $PSScriptRoot '..' '..' 'modules' Import-Module (Join-Path $modules 'Rulebook.Generate.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.Publish.psd1') -Force - -function Format-AnnotationText { - # Workflow command escaping: the message part escapes %, CR and LF; a property value also : and ,. - param([AllowNull()][string]$Text, [switch]$Property) - if ($null -eq $Text) { return '' } - $escaped = $Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') - if ($Property) { $escaped = $escaped.Replace(':', '%3A').Replace(',', '%2C') } - return $escaped -} - -function ConvertTo-SingleLine { - # A message on one Markdown line (list item or paragraph); no table escaping. - param([AllowNull()][string]$Text) - if ([string]::IsNullOrEmpty($Text)) { return '' } - return $Text.Replace("`r", ' ').Replace("`n", ' ') -} - -function Add-Annotation { - param([ValidateSet('error', 'warning', 'notice')][string]$Command = 'error', [string]$File, [Parameter(Mandatory)][string]$Message) - $properties = 'title=Publish' - if ($File) { $properties = "file=$(Format-AnnotationText $File -Property),$properties" } - $line = "::$Command $properties::$(Format-AnnotationText $Message)" - $script:annotations.Add($line) - if ($Command -eq 'error') { $script:errorMessages.Add($Message) } - Write-Host $line -} - -function Write-Text { - param([AllowNull()][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Text) - if ([string]::IsNullOrEmpty($Path)) { return } - [System.IO.File]::AppendAllText($Path, $Text.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) -} +Import-Module (Join-Path $modules 'Rulebook.Action.psd1') -Force # [System.IO.File] resolves a relative path against the process directory, not the PowerShell location. -$resolvePath = { param($Path) if ([string]::IsNullOrEmpty($Path)) { $Path } else { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } -$SummaryPath = & $resolvePath $SummaryPath +$SummaryPath = Resolve-ActionPath $SummaryPath $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } if ([string]::IsNullOrEmpty($StagingPath)) { $StagingPath = Join-Path $tempRoot 'rulebook-publish' } # The manifest sits next to the staging folder, never inside it, so it is not published. if ([string]::IsNullOrEmpty($ManifestPath)) { $ManifestPath = Join-Path $tempRoot 'rulebook-publish.manifest.json' } -$StagingPath = & $resolvePath $StagingPath -$ManifestPath = & $resolvePath $ManifestPath -$script:annotations = [System.Collections.Generic.List[string]]::new() -$script:errorMessages = [System.Collections.Generic.List[string]]::new() -# The first reason the run failed, written as the output 'failure': target, baseUrl-empty, baseUrl-invalid, -# preflight, stage or check (anything else is error). -$script:failure = $null -function Add-Failure { - param([Parameter(Mandatory)][string]$Kind) - if ($null -eq $script:failure) { $script:failure = $Kind } -} +$StagingPath = Resolve-ActionPath $StagingPath +$ManifestPath = Resolve-ActionPath $ManifestPath +# Failure is the first reason the run failed, written as the output 'failure': target, baseUrl-empty, +# baseUrl-invalid, preflight, stage or check (anything else is error). +$ctx = New-ActionContext -Title 'Publish' $summary = [System.Text.StringBuilder]::new() if ($Phase -eq 'Stage') { @@ -111,37 +73,37 @@ if ($Phase -eq 'Stage') { try { $resolvedTarget = Resolve-RulebookPublishTarget -Settings $settings -Override $Target } catch { - Add-Annotation -File $(if ([string]::IsNullOrWhiteSpace($Target)) { $settingsFile }) -Message $_.Exception.Message - Add-Failure 'target' + Add-Annotation -Context $ctx -File $(if ([string]::IsNullOrWhiteSpace($Target)) { $settingsFile }) -Message $_.Exception.Message + Add-Failure -Context $ctx -Kind 'target' $failed = $true } try { $resolvedBaseUrl = Resolve-RulebookBaseUrl -Settings $settings -Repository $Repository -Override $BaseUrl } catch { - Add-Annotation -File $(if ([string]::IsNullOrWhiteSpace($BaseUrl)) { $settingsFile }) -Message $_.Exception.Message + Add-Annotation -Context $ctx -File $(if ([string]::IsNullOrWhiteSpace($BaseUrl)) { $settingsFile }) -Message $_.Exception.Message $empty = [string]::IsNullOrWhiteSpace($BaseUrl) -and [string]::IsNullOrEmpty([string]$settings['baseUrl']) - Add-Failure $(if ($empty) { 'baseUrl-empty' } else { 'baseUrl-invalid' }) + Add-Failure -Context $ctx -Kind $(if ($empty) { 'baseUrl-empty' } else { 'baseUrl-invalid' }) $failed = $true } if (-not $failed) { Write-Host "Publish target: $resolvedTarget, base URL: $resolvedBaseUrl" $site = $settings['site'] if ($site -is [System.Collections.IDictionary] -and $site.Contains('enabled') -and $site['enabled'] -eq $true) { - Add-Annotation -Command notice -Message 'site.enabled is true: the dashboard site arrives with WP14 (https://github.com/ALCops/rulebook-engine/issues/16). This run publishes the plain index.html.' + Add-Annotation -Context $ctx -Command notice -Message 'site.enabled is true: the dashboard site arrives with WP14 (https://github.com/ALCops/rulebook-engine/issues/16). This run publishes the plain index.html.' } if ($Deploy) { if ([string]::IsNullOrEmpty($Repository)) { throw 'The Pages preflight needs the repository (GITHUB_REPOSITORY) as owner/name.' } $preflight = Invoke-PagesPreflight -Repository $Repository -ApiUrl $ApiUrl -Token $Token -BaseUrl $resolvedBaseUrl Write-Host "Pages preflight: HTTP $($preflight.StatusCode). $($preflight.Message)" - if ($preflight.Warning) { Add-Annotation -Command warning -Message $preflight.Warning } - if (-not $preflight.Ok) { Add-Annotation -Message $preflight.Message; Add-Failure 'preflight'; $failed = $true } + if ($preflight.Warning) { Add-Annotation -Context $ctx -Command warning -Message $preflight.Warning } + if (-not $preflight.Ok) { Add-Annotation -Context $ctx -Message $preflight.Message; Add-Failure -Context $ctx -Kind 'preflight'; $failed = $true } } } if (-not $failed) { try { $manifest = @(New-RulebookPublishStage -RepositoryRoot $root -BaseUrl $resolvedBaseUrl -OutputPath $StagingPath -Inputs $inputs -Repository $Repository) } catch { - Add-Failure 'stage' + Add-Failure -Context $ctx -Kind 'stage' throw } $manifestParent = Split-Path -Parent $ManifestPath @@ -150,15 +112,15 @@ if ($Phase -eq 'Stage') { Write-Host ('Staged {0} endpoints, {1} skeletons, rulebook.json and index.html in {2}' -f @($manifest | Where-Object Kind -CEQ 'endpoint').Count, @($manifest | Where-Object Kind -CEQ 'skeleton').Count, $StagingPath) } } catch { - Add-Annotation -Message $_.Exception.Message - Add-Failure 'error' + Add-Annotation -Context $ctx -Message $_.Exception.Message + Add-Failure -Context $ctx -Kind 'error' $failed = $true } - if ($failed -and $env:GITHUB_OUTPUT) { Write-Text -Path $env:GITHUB_OUTPUT -Text "failure=$($script:failure)`n" } if ($failed) { + Write-ActionOutput -Outputs ([ordered]@{ failure = $ctx.Failure }) [void]$summary.AppendLine('Publish stopped before deploying:').AppendLine() - foreach ($message in $script:errorMessages) { [void]$summary.AppendLine('- ' + (ConvertTo-SingleLine $message)) } + foreach ($message in $ctx.ErrorMessages) { [void]$summary.AppendLine('- ' + (ConvertTo-SingleLine $message)) } [void]$summary.AppendLine() } else { $mode = if ($Deploy) { 'Deploying to GitHub Pages' } else { 'Staged only (deploy is off)' } @@ -166,22 +128,20 @@ if ($Phase -eq 'Stage') { [void]$summary.AppendLine('| Kind | URL |').AppendLine('|---|---|') foreach ($entry in $manifest) { [void]$summary.AppendLine(('| {0} | {1} |' -f $entry.Kind, $entry.Url)) } [void]$summary.AppendLine() - if ($env:GITHUB_OUTPUT) { - Write-Text -Path $env:GITHUB_OUTPUT -Text "stagingPath=$StagingPath`nmanifestPath=$ManifestPath`npageUrl=$resolvedBaseUrl/`n" - } + Write-ActionOutput -Outputs ([ordered]@{ stagingPath = $StagingPath; manifestPath = $ManifestPath; pageUrl = "$resolvedBaseUrl/" }) } $summaryText = $summary.ToString().Replace("`r`n", "`n") Write-Text -Path $SummaryPath -Text $summaryText return [pscustomobject]@{ ExitCode = $(if ($failed) { 1 } else { 0 }) Phase = $Phase - Failure = $script:failure + Failure = $ctx.Failure BaseUrl = $resolvedBaseUrl StagingPath = $StagingPath ManifestPath = $ManifestPath Manifest = $manifest Preflight = $preflight - Annotations = $script:annotations.ToArray() + Annotations = $ctx.Annotations.ToArray() Summary = $summaryText } } @@ -205,7 +165,7 @@ try { default { if ($result.Status -gt 0) { "failed (HTTP $($result.Status))" } else { 'failed' } } } if ($result.Detail) { $reason += " ($($result.Detail))" } - Add-Annotation -Message ('{0} {1} after {2} attempt(s) in {3} s. Consumers of this URL compile with AL1033 (alc aborts; VS Code falls back to the analyzer defaults).' -f $result.Url, $reason, $result.Attempts, $result.Seconds) + Add-Annotation -Context $ctx -Message ('{0} {1} after {2} attempt(s) in {3} s. Consumers of this URL compile with AL1033 (alc aborts; VS Code falls back to the analyzer defaults).' -f $result.Url, $reason, $result.Attempts, $result.Seconds) $failed = $true } $okCount = @($results | Where-Object Reason -CEQ 'ok').Count @@ -218,7 +178,7 @@ try { } [void]$summary.AppendLine() } catch { - Add-Annotation -Message $_.Exception.Message + Add-Annotation -Context $ctx -Message $_.Exception.Message [void]$summary.AppendLine((ConvertTo-SingleLine $_.Exception.Message)).AppendLine() $failed = $true } @@ -229,6 +189,6 @@ Write-Text -Path $SummaryPath -Text $summaryText Phase = $Phase Failure = $(if ($failed) { 'check' }) Results = $results - Annotations = $script:annotations.ToArray() + Annotations = $ctx.Annotations.ToArray() Summary = $summaryText } diff --git a/actions/ScanDiagnostics/ScanDiagnostics.ps1 b/actions/ScanDiagnostics/ScanDiagnostics.ps1 index 6e1d327..55ab955 100644 --- a/actions/ScanDiagnostics/ScanDiagnostics.ps1 +++ b/actions/ScanDiagnostics/ScanDiagnostics.ps1 @@ -53,56 +53,19 @@ Import-Module (Join-Path $modules 'Rulebook.GitHub.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.Update.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.Quarantine.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.Scan.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.Action.psd1') -Force $docsUrl = 'https://github.com/ALCops/rulebook/blob/main/docs/ghtokenworkflow.md' -# The helpers below are copies of CheckForUpdates.ps1 (a shared action helper module is #58, WP12). -function Format-AnnotationText { - # Workflow command escaping: the message part escapes %, CR and LF; a property value also : and ,. - param([AllowNull()][string]$Text, [switch]$Property) - if ($null -eq $Text) { return '' } - $escaped = $Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') - if ($Property) { $escaped = $escaped.Replace(':', '%3A').Replace(',', '%2C') } - return $escaped -} - -function ConvertTo-SingleLine { - # A message on one Markdown line (list item or paragraph); no table escaping. - param([AllowNull()][string]$Text) - if ([string]::IsNullOrEmpty($Text)) { return '' } - return $Text.Replace("`r", ' ').Replace("`n", ' ') -} - -function Add-Annotation { - param([ValidateSet('error', 'warning', 'notice')][string]$Command = 'error', [string]$File, [string]$Title = 'ScanDiagnostics', [Parameter(Mandatory)][string]$Message) - $properties = "title=$(Format-AnnotationText $Title -Property)" - if ($File) { $properties = "file=$(Format-AnnotationText $File -Property),$properties" } - $line = "::$Command $properties::$(Format-AnnotationText $Message)" - $script:annotations.Add($line) - Write-Host $line -} - -function Write-Text { - param([AllowNull()][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Text) - if ([string]::IsNullOrEmpty($Path)) { return } - [System.IO.File]::AppendAllText($Path, $Text.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) -} - # [System.IO.File] resolves a relative path against the process directory, not the PowerShell location. -$resolvePath = { param($Path) if ([string]::IsNullOrEmpty($Path)) { $Path } else { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } -$SummaryPath = & $resolvePath $SummaryPath +$SummaryPath = Resolve-ActionPath $SummaryPath $ownWork = [string]::IsNullOrEmpty($WorkPath) if ($ownWork) { $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } $WorkPath = Join-Path $tempRoot ('rulebook-scan-' + [guid]::NewGuid().ToString('n').Substring(0, 8)) } -$WorkPath = & $resolvePath $WorkPath -$script:annotations = [System.Collections.Generic.List[string]]::new() -$script:failure = $null -function Add-Failure { - param([Parameter(Mandatory)][string]$Kind) - if ($null -eq $script:failure) { $script:failure = $Kind } -} +$WorkPath = Resolve-ActionPath $WorkPath +$ctx = New-ActionContext -Title 'ScanDiagnostics' # Every token obtained by an exchange is masked before anything can print it. $maskToken = { param([string]$Value) if (-not [string]::IsNullOrEmpty($Value)) { Write-Host "::add-mask::$Value" } } @@ -134,7 +97,7 @@ try { try { $null = Get-QuarantinePolicy -Settings $settings } catch { - Add-Failure 'policy' + Add-Failure -Context $ctx -Kind 'policy' throw } @@ -142,11 +105,11 @@ try { $access = $null if (-not $DryRun) { if (-not [string]::IsNullOrEmpty($name) -and -not $nameValid) { - Add-Failure 'token' + Add-Failure -Context $ctx -Kind 'token' throw "ghTokenWorkflowSecretName '$name' in .github/Rulebook-Settings.json is not a valid secret name (letters, digits and underscores, not starting with a digit or GITHUB_). Read $docsUrl" } if ([string]::IsNullOrWhiteSpace($Token)) { - Add-Failure 'token' + Add-Failure -Context $ctx -Kind 'token' throw "The $secretName secret is needed to scan diagnostics. Read $docsUrl" } if ([string]::IsNullOrEmpty($Repository)) { throw 'The scan needs the repository (GITHUB_REPOSITORY) as owner/name.' } @@ -154,7 +117,7 @@ try { try { $access = Get-GitHubAccessToken -Token $Token -Repository $Repository -ApiUrl $ApiUrl } catch { - Add-Failure 'token' + Add-Failure -Context $ctx -Kind 'token' throw "The $secretName secret could not be used: $($_.Exception.Message)" } if (-not [string]::IsNullOrEmpty($access.Token)) { & $maskToken $access.Token } @@ -167,7 +130,7 @@ try { foreach ($item in $plan.Scanned) { Write-Host "Scanned $($item.PackageId) $($item.Version) ($($item.Channel)): $($item.Records.Records.Count) ids, $(@($item.Diff.NewIds).Count) new, $($item.Extraction.ElapsedSeconds) s in the child process on .NET $($item.Extraction.Runtime)" } foreach ($note in $plan.Notes) { Write-Host "Note: $note" } if ($plan.Failure) { - Add-Failure $plan.Failure + Add-Failure -Context $ctx -Kind $plan.Failure $what = switch ($plan.Failure) { 'nuget' { 'The NuGet packages could not be read' } 'extract' { 'The diagnostics could not be extracted' } default { 'The scan failed' } } throw "$what`: $($plan.FailureMessage)" } @@ -176,7 +139,7 @@ try { # 5. Nothing new. $versions = @($plan.Channels | ForEach-Object { "$($_.PackageId) $($_.Stable) stable" + $(if ($_.Prerelease) { ", $($_.Prerelease) prerelease" } else { '' }) }) $summaryMessage = "No new package version ($($versions -join '; ')); nothing to do" - Add-Annotation -Command notice -Message $summaryMessage + Add-Annotation -Context $ctx -Command notice -Message $summaryMessage $result = 'nothing-new' } elseif (-not $plan.Valid) { # 6. The candidate does not validate. @@ -187,19 +150,19 @@ try { $message += "; add $($finding.Id) to catalog/diagnostics.json or remove it from $($finding.File); the scan writes catalog/scan-state.json, which turns the C7 warning into an error" } $file = if ($finding.File) { [System.IO.Path]::GetRelativePath($workspace, (Join-Path $root $finding.File)).Replace($separator, '/') } else { $null } - Add-Annotation -File $file -Title $finding.Rule -Message "The scanned rulebook would not validate: $message" + Add-Annotation -Context $ctx -File $file -Title $finding.Rule -Message "The scanned rulebook would not validate: $message" } - Add-Failure 'validation' + Add-Failure -Context $ctx -Kind 'validation' $summaryMessage = 'The scanned rulebook does not validate; nothing was pushed. The findings come from the repository after the scan.' $failed = $true } elseif ($DryRun) { # 7. Dry run. $summaryMessage = "Dry run: $(Get-ScanTitle -Plan $plan); nothing was pushed" - Add-Annotation -Command notice -Message $summaryMessage + Add-Annotation -Context $ctx -Command notice -Message $summaryMessage $result = 'dry-run' } else { # 8. Publish. - if (-not $plan.HeadSha) { Add-Annotation -Command warning -Message 'base-move guard inactive: the checkout HEAD could not be read' } + if (-not $plan.HeadSha) { Add-Annotation -Context $ctx -Command warning -Message 'base-move guard inactive: the checkout HEAD could not be read' } $labels = [string[]]@(& $setting 'commitOptions', 'pullRequestLabels' | Where-Object { $_ -is [string] -and $_ -ne '' }) try { $publishParameters = @{ @@ -210,7 +173,7 @@ try { } catch { $stage = [string]$_.Exception.Data['Stage'] if ($stage -cnotin 'push', 'pull-request') { $stage = 'push' } - Add-Failure $stage + Add-Failure -Context $ctx -Kind $stage # The base branch moved between plan and publish: nothing is wrong with the token, the next run picks it up. if ([string]$_.Exception.Data['Reason'] -ceq 'base-moved') { throw $_.Exception.Message } $what = if ($stage -eq 'pull-request') { 'Failed to create or update the scan pull request' } else { 'Failed to push the scan' } @@ -223,12 +186,12 @@ try { 'pull-request-updated' { $summaryMessage = "Pull request updated: $($publish.PullRequestUrl)" } default { $summaryMessage = "Pull request: $($publish.PullRequestUrl)" + $(if ($publish.Fallback) { ' (the direct commit was refused)' } else { '' }) } } - Add-Annotation -Command notice -Message $summaryMessage - if ($publish.PSObject.Properties['ClosedPullRequestUrl'] -and $publish.ClosedPullRequestUrl) { Add-Annotation -Command notice -Message "Pull request closed: $($publish.ClosedPullRequestUrl)" } + Add-Annotation -Context $ctx -Command notice -Message $summaryMessage + if ($publish.PSObject.Properties['ClosedPullRequestUrl'] -and $publish.ClosedPullRequestUrl) { Add-Annotation -Context $ctx -Command notice -Message "Pull request closed: $($publish.ClosedPullRequestUrl)" } } } catch { - Add-Annotation -Message $_.Exception.Message - Add-Failure 'error' + Add-Annotation -Context $ctx -Message $_.Exception.Message + Add-Failure -Context $ctx -Kind 'error' $summaryMessage = $_.Exception.Message $failed = $true } finally { @@ -260,18 +223,16 @@ $outputs = [ordered]@{ pullRequestUrl = $(if ($null -ne $publish -and $publish.PullRequestUrl) { $publish.PullRequestUrl } elseif ($null -ne $publish -and $publish.PSObject.Properties['ClosedPullRequestUrl'] -and $publish.ClosedPullRequestUrl) { $publish.ClosedPullRequestUrl } else { '' }) candidatePath = $(if ($null -ne $plan -and $plan.CandidatePath -and $result -eq 'dry-run') { $plan.CandidatePath } else { '' }) elapsedSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1).ToString([System.Globalization.CultureInfo]::InvariantCulture) - failure = $script:failure -} -if ($env:GITHUB_OUTPUT) { - Write-Text -Path $env:GITHUB_OUTPUT -Text ((@($outputs.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "`n") + "`n") + failure = $ctx.Failure } +Write-ActionOutput -Outputs $outputs [pscustomobject]@{ ExitCode = $(if ($failed) { 1 } else { 0 }) Result = $outputs.result - Failure = $script:failure + Failure = $ctx.Failure Plan = $plan Publish = $publish - Annotations = $script:annotations.ToArray() + Annotations = $ctx.Annotations.ToArray() Summary = $summary Outputs = $outputs } diff --git a/actions/Validate/Validate.ps1 b/actions/Validate/Validate.ps1 index 82b3246..ca30dab 100644 --- a/actions/Validate/Validate.ps1 +++ b/actions/Validate/Validate.ps1 @@ -40,21 +40,7 @@ Set-StrictMode -Version 3.0 $modules = Join-Path $PSScriptRoot '..' '..' 'modules' Import-Module (Join-Path $modules 'Rulebook.Generate.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.Validate.psd1') -Force - -function Format-AnnotationText { - # Workflow command escaping: the message part escapes %, CR and LF; a property value also : and ,. - param([AllowNull()][string]$Text, [switch]$Property) - if ($null -eq $Text) { return '' } - $escaped = $Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') - if ($Property) { $escaped = $escaped.Replace(':', '%3A').Replace(',', '%2C') } - return $escaped -} - -function Format-TableCell { - param([AllowNull()][string]$Text) - if ([string]::IsNullOrEmpty($Text)) { return '' } - return $Text.Replace('|', '\|').Replace("`r", ' ').Replace("`n", ' ') -} +Import-Module (Join-Path $modules 'Rulebook.Action.psd1') -Force function Test-GitRef { param([string]$Root, [string]$Ref) @@ -64,9 +50,9 @@ function Test-GitRef { } # [System.IO.File] resolves a relative path against the process directory, not the PowerShell location. -$resolvePath = { param($Path) if ([string]::IsNullOrEmpty($Path)) { $Path } else { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } -$SummaryPath = & $resolvePath $SummaryPath -$JsonPath = & $resolvePath $JsonPath +$SummaryPath = Resolve-ActionPath $SummaryPath +$JsonPath = Resolve-ActionPath $JsonPath +$ctx = New-ActionContext -Title 'Validate' $root = (Resolve-Path -LiteralPath $RepositoryRoot).ProviderPath $workspace = if ([string]::IsNullOrEmpty($WorkspaceRoot)) { $root } else { (Resolve-Path -LiteralPath $WorkspaceRoot).ProviderPath } $separator = [System.IO.Path]::DirectorySeparatorChar @@ -80,18 +66,11 @@ $errorCount = @($findings | Where-Object Severity -EQ 'error').Count $warningCount = @($findings | Where-Object Severity -EQ 'warning').Count # 2. Annotations -$annotations = [System.Collections.Generic.List[string]]::new() foreach ($finding in $findings) { $command = if ($finding.Severity -eq 'error') { 'error' } else { 'warning' } $message = if ($finding.Id) { "$($finding.Id): $($finding.Message)" } else { $finding.Message } - $properties = "title=$(Format-AnnotationText $finding.Rule -Property)" - if ($finding.File) { - $path = [System.IO.Path]::GetRelativePath($workspace, (Join-Path $root $finding.File)).Replace($separator, '/') - $properties = "file=$(Format-AnnotationText $path -Property),$properties" - } - $line = "::$command $properties::$(Format-AnnotationText $message)" - $annotations.Add($line) - Write-Host $line + $path = if ($finding.File) { [System.IO.Path]::GetRelativePath($workspace, (Join-Path $root $finding.File)).Replace($separator, '/') } else { $null } + Add-Annotation -Context $ctx -Command $command -File $path -Title $finding.Rule -Message $message } Write-Host ('Rulebook validation: {0} error(s), {1} warning(s) in {2}' -f $errorCount, $warningCount, $relativeRoot) @@ -191,12 +170,8 @@ if ($null -ne $diffNote) { $summaryText = $summary.ToString().Replace("`r`n", "`n") # The runner caps a step summary at 1 MiB; stay well below it. $summaryLimit = $SummaryLimit -if ([System.Text.Encoding]::UTF8.GetByteCount($summaryText) -gt $summaryLimit) { - $cut = [math]::Min($summaryText.Length, $summaryLimit) - while ([System.Text.Encoding]::UTF8.GetByteCount($summaryText.Substring(0, $cut)) -gt $summaryLimit) { $cut = [int]($cut * 0.9) } - $summaryText = $summaryText.Substring(0, $cut) + "`n`n_The summary was truncated at 900 KiB; the -JsonPath file and the annotations above have the findings._`n" -} -if ($SummaryPath) { [System.IO.File]::AppendAllText($SummaryPath, $summaryText, [System.Text.UTF8Encoding]::new($false)) } +$summaryText = Limit-SummaryText -Text $summaryText -MaxBytes $summaryLimit -Footer 'The summary was truncated at 900 KiB; the -JsonPath file and the annotations above have the findings.' +Write-Text -Path $SummaryPath -Text $summaryText # 5. Update check (WP07): check mode only, never counted, never failing. $updateCheck = $null @@ -211,7 +186,7 @@ if ($CheckForUpdates) { Import-Module (Join-Path $modules 'Rulebook.GitHub.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.Update.psd1') -Force if ($UpdateWorkPath) { - $work = & $resolvePath $UpdateWorkPath + $work = Resolve-ActionPath $UpdateWorkPath } else { $work = Join-Path $(if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() }) ('rulebook-update-check-' + [guid]::NewGuid().ToString('n').Substring(0, 8)) $ownWork = $true @@ -237,27 +212,19 @@ if ($CheckForUpdates) { } finally { if ($ownWork -and (Test-Path -LiteralPath $work)) { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue } } - $line = "::$command title=Update check::$(Format-AnnotationText $message)" - $annotations.Add($line) - Write-Host $line + Add-Annotation -Context $ctx -Command $command -Title 'Update check' -Message $message $updateSummary = if ($null -ne $updateCheck.Plan) { ConvertTo-UpdateSummary -Plan $updateCheck.Plan -Mode check -Message $message } else { "## Template update check`n`n$message`n`n" } $updateSummary = $updateSummary.Replace("`r`n", "`n") # Within what the validation summary leaves of the cap: cut at a line boundary, never dropped. $budget = $summaryLimit - [System.Text.Encoding]::UTF8.GetByteCount($summaryText) $footer = "The update check summary was cut at $([math]::Floor($summaryLimit / 1KB)) KiB; the full lists are in the job log." - if (Get-Command Limit-SummaryText -ErrorAction SilentlyContinue) { - $updateSummary = Limit-SummaryText -Text $updateSummary -MaxBytes ([math]::Max(0, $budget)) -Footer $footer - } elseif ([System.Text.Encoding]::UTF8.GetByteCount($updateSummary) -gt $budget) { - $updateSummary = "## Template update check`n`n_$($footer)_`n" - } + $updateSummary = Limit-SummaryText -Text $updateSummary -MaxBytes ([math]::Max(0, $budget)) -Footer $footer $summaryText += $updateSummary - if ($SummaryPath) { [System.IO.File]::AppendAllText($SummaryPath, $updateSummary, [System.Text.UTF8Encoding]::new($false)) } + Write-Text -Path $SummaryPath -Text $updateSummary } # 6. Outputs -if ($env:GITHUB_OUTPUT) { - [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "errors=$errorCount`nwarnings=$warningCount`n", [System.Text.UTF8Encoding]::new($false)) -} +Write-ActionOutput -Outputs ([ordered]@{ errors = $errorCount; warnings = $warningCount }) $exitCode = if ($errorCount -gt 0 -or ($FailOnWarning -and $warningCount -gt 0)) { 1 } else { 0 } [pscustomobject]@{ @@ -265,7 +232,7 @@ $exitCode = if ($errorCount -gt 0 -or ($FailOnWarning -and $warningCount -gt 0)) Findings = $findings Diff = $diff Summary = $summaryText - Annotations = $annotations.ToArray() + Annotations = $ctx.Annotations.ToArray() DiffRef = $DiffRef UpdateCheck = $updateCheck } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7306ebc..9a55ce1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -242,7 +242,7 @@ A rule is `id` (`^[A-Z]{2,3}[0-9]{4}i?$`), `action` (`Error`, `Warning`, `Info`, **Validate action** -`actions/Validate/action.yaml` is a composite action. Inputs: `repositoryRoot` (default `.`), `failOnWarning` (default `'false'`), `checkForUpdates` (default `'true'`, the template update check of section 7.3 in check mode). Outputs: `errors`, `warnings`. One `pwsh` step receives the inputs and the read-only `GITHUB_TOKEN` through `env:` and runs `Validate.ps1` from `GITHUB_ACTION_PATH`, which imports the modules from `../../modules` of the same engine checkout, and exits with its `ExitCode`. +`actions/Validate/action.yaml` is a composite action. Inputs: `repositoryRoot` (default `.`), `failOnWarning` (default `'false'`), `checkForUpdates` (default `'true'`, the template update check of section 7.3 in check mode). Outputs: `errors`, `warnings`. One `pwsh` step receives the inputs and the read-only `GITHUB_TOKEN` through `env:` and runs `Validate.ps1` from `GITHUB_ACTION_PATH`, which imports the modules from `../../modules` of the same engine checkout, and exits with its `ExitCode`. Like the entry scripts of the other actions, `Validate.ps1` writes its annotations, summary and outputs through `modules/Rulebook.Action` (#58), a leaf module without engine imports. `Validate.ps1` (`-RepositoryRoot`, `-FailOnWarning`, `-CheckForUpdates`, `-DiffRef`, `-SummaryPath`, `-JsonPath`, `-WorkspaceRoot`, and the test seams `-TemplatePath`, `-InstalledTemplatePath`, `-ApiUrl`, `-UpdateWorkPath`) never calls `exit` and returns `{ ExitCode, Findings, Diff, Summary, Annotations, DiffRef, UpdateCheck }`: diff --git a/modules/Rulebook.Action.psd1 b/modules/Rulebook.Action.psd1 new file mode 100644 index 0000000..9f9bfd2 --- /dev/null +++ b/modules/Rulebook.Action.psd1 @@ -0,0 +1,33 @@ +@{ + RootModule = 'Rulebook.Action.psm1' + ModuleVersion = '0.1.0' + GUID = '570fccc7-5902-4294-a9ee-6537554ffd8e' + Author = 'ALCops' + CompanyName = 'ALCops' + Copyright = '(c) ALCops. MIT License.' + Description = 'Rulebook action helpers shared by the entry scripts under actions/: workflow command escaping and annotations, the run context, the job summary and GITHUB_OUTPUT writers, path resolution and the Markdown helpers for summaries. A leaf module without engine imports.' + PowerShellVersion = '7.4' + CompatiblePSEditions = @('Core') + FunctionsToExport = @( + 'Add-Annotation' + 'Add-Failure' + 'ConvertTo-SingleLine' + 'Format-AnnotationText' + 'Format-TableCell' + 'Limit-SummaryText' + 'New-ActionContext' + 'Resolve-ActionPath' + 'Write-ActionOutput' + 'Write-Text' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('ALCops', 'Rulebook', 'AL', 'BusinessCentral', 'ruleset') + LicenseUri = 'https://github.com/ALCops/rulebook-engine/blob/main/LICENSE' + ProjectUri = 'https://github.com/ALCops/rulebook-engine' + } + } +} diff --git a/modules/Rulebook.Action.psm1 b/modules/Rulebook.Action.psm1 new file mode 100644 index 0000000..54bc14e --- /dev/null +++ b/modules/Rulebook.Action.psm1 @@ -0,0 +1,202 @@ +#requires -Version 7.4 +# Rulebook.Action: the helpers the entry scripts under actions/ share (#58). Workflow command escaping and the +# annotation lines, the run context (annotations, error messages, the first failure kind), the job summary and +# GITHUB_OUTPUT writers, path resolution against the PowerShell location, and the Markdown helpers for summaries and +# pull request bodies (Format-TableCell, ConvertTo-SingleLine, Limit-SummaryText). A leaf module: it imports no other +# engine module, so every module and entry script can import it. + +Set-StrictMode -Version 3.0 + +function Format-AnnotationText { + <# + .SYNOPSIS + Workflow command escaping: the message part escapes %, CR and LF; a property value (-Property) also : and ,. + .DESCRIPTION + $null comes back as ''. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Position = 0)][AllowNull()][AllowEmptyString()][string]$Text, [switch]$Property) + if ($null -eq $Text) { return '' } + $escaped = $Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + if ($Property) { $escaped = $escaped.Replace(':', '%3A').Replace(',', '%2C') } + return $escaped +} + +function ConvertTo-SingleLine { + <# + .SYNOPSIS + A message on one Markdown line (list item or paragraph): CR and LF become spaces; no table escaping. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Position = 0)][AllowNull()][AllowEmptyString()][string]$Text) + if ([string]::IsNullOrEmpty($Text)) { return '' } + return $Text.Replace("`r", ' ').Replace("`n", ' ') +} + +function Format-TableCell { + <# + .SYNOPSIS + Text for a Markdown table cell: | escaped, CR and LF become spaces. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Position = 0)][AllowNull()][AllowEmptyString()][string]$Text) + if ([string]::IsNullOrEmpty($Text)) { return '' } + return $Text.Replace('|', '\|').Replace("`r", ' ').Replace("`n", ' ') +} + +function New-ActionContext { + <# + .SYNOPSIS + The run context of an entry script: { Title, Annotations, ErrorMessages, Failure }. + .DESCRIPTION + Title is the default annotation title (the action name). Annotations collects every workflow command line + Add-Annotation writes, ErrorMessages the message of every error annotation (unescaped), and Failure holds the + first failure kind Add-Failure records ($null until then). + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an object; changes no state')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][string]$Title) + return [pscustomobject]@{ + Title = $Title + Annotations = [System.Collections.Generic.List[string]]::new() + ErrorMessages = [System.Collections.Generic.List[string]]::new() + Failure = $null + } +} + +function Add-Annotation { + <# + .SYNOPSIS + Writes one workflow command, :: [file=,]title=::<message>, and collects it in -Context. + .DESCRIPTION + -Title defaults to the title of the context. File and title are escaped as property values, the message as the + message part. An error annotation also adds -Message (unescaped) to Context.ErrorMessages. Returns nothing. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Context, + [ValidateSet('error', 'warning', 'notice')][string]$Command = 'error', + [AllowNull()][AllowEmptyString()][string]$File, + [AllowNull()][AllowEmptyString()][string]$Title, + [Parameter(Mandatory)][AllowNull()][AllowEmptyString()][string]$Message + ) + if (-not $PSBoundParameters.ContainsKey('Title')) { $Title = $Context.Title } + $properties = "title=$(Format-AnnotationText $Title -Property)" + if ($File) { $properties = "file=$(Format-AnnotationText $File -Property),$properties" } + $line = "::$Command $properties::$(Format-AnnotationText $Message)" + $Context.Annotations.Add($line) + if ($Command -eq 'error') { $Context.ErrorMessages.Add($Message) } + Write-Host $line +} + +function Add-Failure { + <# + .SYNOPSIS + Records the failure kind of the run in -Context; the first kind wins. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][pscustomobject]$Context, [Parameter(Mandatory)][string]$Kind) + if ($null -eq $Context.Failure) { $Context.Failure = $Kind } +} + +function Resolve-ActionPath { + <# + .SYNOPSIS + A path made absolute against the PowerShell location ([System.IO.File] would resolve it against the process + directory). An empty path comes back as it is; the path need not exist. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Position = 0)][AllowNull()][AllowEmptyString()][string]$Path) + if ([string]::IsNullOrEmpty($Path)) { return $Path } + return $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) +} + +function Write-Text { + <# + .SYNOPSIS + Appends -Text to the file -Path (UTF-8 without BOM, CRLF written as LF); nothing when -Path is empty. + #> + [CmdletBinding()] + param([AllowNull()][AllowEmptyString()][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Text) + if ([string]::IsNullOrEmpty($Path)) { return } + [System.IO.File]::AppendAllText((Resolve-ActionPath $Path), $Text.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) +} + +function Write-ActionOutput { + <# + .SYNOPSIS + Appends one key=value line per entry of -Outputs, in its order, to -Path (default GITHUB_OUTPUT). + .DESCRIPTION + A value is written as PowerShell interpolates it ($null as ''). Nothing is written when -Path is empty or + -Outputs has no entry. Values are expected on one line. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.Collections.IDictionary]$Outputs, + [AllowNull()][AllowEmptyString()][string]$Path = $env:GITHUB_OUTPUT + ) + if ([string]::IsNullOrEmpty($Path) -or $Outputs.Count -eq 0) { return } + $text = [System.Text.StringBuilder]::new() + foreach ($entry in $Outputs.GetEnumerator()) { [void]$text.Append("$($entry.Key)=$($entry.Value)`n") } + Write-Text -Path $Path -Text $text.ToString() +} + +function Limit-SummaryText { + <# + .SYNOPSIS + Markdown cut below -MaxBytes (UTF-8) at a line boundary, an open code fence closed, and -Footer as an italic line. + .DESCRIPTION + Text within the limit comes back as it is. Otherwise whole lines are kept from the start (the first line, a + heading, always), an open ``` or ~~~ fence in the kept part is closed, and '_<Footer>_' ends the text. Used for + the job summaries of the CheckForUpdates and Validate actions. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$Text, + [Parameter(Mandatory)][int]$MaxBytes, + [Parameter(Mandatory)][string]$Footer + ) + $utf8 = [System.Text.UTF8Encoding]::new($false) + if ($utf8.GetByteCount($Text) -le $MaxBytes) { return $Text } + $footerText = "`n_$($Footer)_`n" + $lines = $Text.Split("`n") + $kept = [System.Collections.Generic.List[string]]::new() + $fence = $null + $used = 0 + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + # Room for this line, the footer and a fence closer of the current or a newly opened fence. + $closer = if ($null -ne $fence) { $fence.Length + 1 } else { $line.Length + 1 } + if ($i -gt 0 -and $used + $utf8.GetByteCount($line) + 1 + $closer + $utf8.GetByteCount($footerText) -gt $MaxBytes) { break } + $kept.Add($line) + $used += $utf8.GetByteCount($line) + 1 + $marker = [regex]::Match($line, '^[ ]{0,3}(`{3,}|~{3,})') + if ($null -ne $fence) { + if ($marker.Success -and $marker.Groups[1].Value[0] -ceq $fence[0] -and $marker.Groups[1].Value.Length -ge $fence.Length -and $line.Trim() -ceq $marker.Groups[1].Value) { $fence = $null } + } elseif ($marker.Success) { + $fence = $marker.Groups[1].Value + } + } + $result = ($kept -join "`n") + "`n" + if ($null -ne $fence) { $result += "$fence`n" } + return $result + $footerText +} + +Export-ModuleMember -Function @( + 'Add-Annotation' + 'Add-Failure' + 'ConvertTo-SingleLine' + 'Format-AnnotationText' + 'Format-TableCell' + 'Limit-SummaryText' + 'New-ActionContext' + 'Resolve-ActionPath' + 'Write-ActionOutput' + 'Write-Text' +) diff --git a/modules/Rulebook.Scan.psm1 b/modules/Rulebook.Scan.psm1 index cda343e..8f7d9e2 100644 --- a/modules/Rulebook.Scan.psm1 +++ b/modules/Rulebook.Scan.psm1 @@ -16,6 +16,7 @@ Import-Module (Join-Path $PSScriptRoot 'Rulebook.NuGet.psd1') Import-Module (Join-Path $PSScriptRoot 'Rulebook.Catalog.psd1') Import-Module (Join-Path $PSScriptRoot 'Rulebook.Extract.psd1') Import-Module (Join-Path $PSScriptRoot 'Rulebook.Quarantine.psd1') +Import-Module (Join-Path $PSScriptRoot 'Rulebook.Action.psd1') $script:ToolsPackageId = 'microsoft.dynamics.businesscentral.development.tools' $script:AlcopsPackageId = 'alcops.analyzers' diff --git a/modules/Rulebook.Update.psd1 b/modules/Rulebook.Update.psd1 index dec0ee6..abb5eb7 100644 --- a/modules/Rulebook.Update.psd1 +++ b/modules/Rulebook.Update.psd1 @@ -12,7 +12,6 @@ 'Compare-CustomizableFile' 'Get-ShortSha' 'Copy-UpdateTree' - 'Format-TableCell' 'Get-ComparableContent' 'Get-DefaultWorkPath' 'Get-TreeFile' @@ -28,7 +27,6 @@ 'Get-RulebookUpdatePlan' 'Get-RulebookUpdateStatus' 'Get-TemplateContentSha' - 'Limit-SummaryText' 'Publish-RulebookUpdate' 'Update-RulebookSettingsText' ) diff --git a/modules/Rulebook.Update.psm1 b/modules/Rulebook.Update.psm1 index 91d1aa6..218467d 100644 --- a/modules/Rulebook.Update.psm1 +++ b/modules/Rulebook.Update.psm1 @@ -11,6 +11,7 @@ Import-Module (Join-Path $PSScriptRoot 'Rulebook.Generate.psd1') Import-Module (Join-Path $PSScriptRoot 'Rulebook.Validate.psd1') Import-Module (Join-Path $PSScriptRoot 'Rulebook.Template.psd1') Import-Module (Join-Path $PSScriptRoot 'Rulebook.GitHub.psd1') +Import-Module (Join-Path $PSScriptRoot 'Rulebook.Action.psd1') $script:SettingsPath = '.github/Rulebook-Settings.json' $script:ReleaseNotesPath = '.github/RELEASENOTES.copy.md' @@ -216,12 +217,6 @@ function Format-YamlScalar { return "'" + $Value.Replace("'", "''") + "'" } -function Format-TableCell { - param([AllowNull()][string]$Text) - if ([string]::IsNullOrEmpty($Text)) { return '' } - return $Text.Replace('|', '\|').Replace("`r", ' ').Replace("`n", ' ') -} - function Get-ShortSha { param([AllowNull()][AllowEmptyString()][string]$Sha) if ([string]::IsNullOrEmpty($Sha)) { return '' } @@ -1239,48 +1234,6 @@ function ConvertTo-UpdateSummary { return $text.ToString().Replace("`r`n", "`n") } -function Limit-SummaryText { - <# - .SYNOPSIS - Markdown cut below -MaxBytes (UTF-8) at a line boundary, an open code fence closed, and -Footer as an italic line. - .DESCRIPTION - Text within the limit comes back as it is. Otherwise whole lines are kept from the start (the first line, a - heading, always), an open ``` or ~~~ fence in the kept part is closed, and '_<Footer>_' ends the text. Used for - the job summaries of the CheckForUpdates and Validate actions. - #> - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory)][AllowEmptyString()][string]$Text, - [Parameter(Mandatory)][int]$MaxBytes, - [Parameter(Mandatory)][string]$Footer - ) - $utf8 = [System.Text.UTF8Encoding]::new($false) - if ($utf8.GetByteCount($Text) -le $MaxBytes) { return $Text } - $footerText = "`n_$($Footer)_`n" - $lines = $Text.Split("`n") - $kept = [System.Collections.Generic.List[string]]::new() - $fence = $null - $used = 0 - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - # Room for this line, the footer and a fence closer of the current or a newly opened fence. - $closer = if ($null -ne $fence) { $fence.Length + 1 } else { $line.Length + 1 } - if ($i -gt 0 -and $used + $utf8.GetByteCount($line) + 1 + $closer + $utf8.GetByteCount($footerText) -gt $MaxBytes) { break } - $kept.Add($line) - $used += $utf8.GetByteCount($line) + 1 - $marker = [regex]::Match($line, '^[ ]{0,3}(`{3,}|~{3,})') - if ($null -ne $fence) { - if ($marker.Success -and $marker.Groups[1].Value[0] -ceq $fence[0] -and $marker.Groups[1].Value.Length -ge $fence.Length -and $line.Trim() -ceq $marker.Groups[1].Value) { $fence = $null } - } elseif ($marker.Success) { - $fence = $marker.Groups[1].Value - } - } - $result = ($kept -join "`n") + "`n" - if ($null -ne $fence) { $result += "$fence`n" } - return $result + $footerText -} - function Publish-RulebookUpdate { <# .SYNOPSIS @@ -1405,7 +1358,6 @@ Export-ModuleMember -Function @( 'Compare-CustomizableFile' 'Get-ShortSha' 'Copy-UpdateTree' - 'Format-TableCell' 'Get-ComparableContent' 'Get-DefaultWorkPath' 'Get-TreeFile' @@ -1421,7 +1373,6 @@ Export-ModuleMember -Function @( 'Get-RulebookUpdatePlan' 'Get-RulebookUpdateStatus' 'Get-TemplateContentSha' - 'Limit-SummaryText' 'Publish-RulebookUpdate' 'Update-RulebookSettingsText' ) diff --git a/tests/CheckForUpdates.Action.Tests.ps1 b/tests/CheckForUpdates.Action.Tests.ps1 index 1780e7e..826e3b5 100644 --- a/tests/CheckForUpdates.Action.Tests.ps1 +++ b/tests/CheckForUpdates.Action.Tests.ps1 @@ -42,7 +42,7 @@ AfterAll { $env:GITHUB_STEP_SUMMARY = $script:saved.Summary $env:GITHUB_REPOSITORY = $script:saved.Repository $env:GITHUB_TOKEN = $script:saved.Token - Remove-Module Rulebook.Update, Rulebook.GitHub, Rulebook.Template, Rulebook.Validate, Rulebook.Generate -ErrorAction SilentlyContinue + Remove-Module Rulebook.Update, Rulebook.GitHub, Rulebook.Template, Rulebook.Validate, Rulebook.Generate, Rulebook.Action -ErrorAction SilentlyContinue } Describe 'actions/CheckForUpdates/action.yaml' { diff --git a/tests/Publish.Action.Tests.ps1 b/tests/Publish.Action.Tests.ps1 index 9b6ebb0..945f384 100644 --- a/tests/Publish.Action.Tests.ps1 +++ b/tests/Publish.Action.Tests.ps1 @@ -52,7 +52,7 @@ AfterAll { $env:GITHUB_STEP_SUMMARY = $script:saved.Summary $env:GITHUB_REPOSITORY = $script:saved.Repository $env:INPUT_TOKEN = $script:saved.Token - Remove-Module Rulebook.Publish, Rulebook.Generate -ErrorAction SilentlyContinue + Remove-Module Rulebook.Publish, Rulebook.Generate, Rulebook.Action -ErrorAction SilentlyContinue } Describe 'actions/Publish/action.yaml' { diff --git a/tests/Rulebook.Action.Tests.ps1 b/tests/Rulebook.Action.Tests.ps1 new file mode 100644 index 0000000..bf9e2d4 --- /dev/null +++ b/tests/Rulebook.Action.Tests.ps1 @@ -0,0 +1,216 @@ +# Action helper suite for #58: modules/Rulebook.Action, the helpers the entry scripts under actions/ share. The four +# action suites cover the helpers again through the exact lines, summaries and outputs of each action. + +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $repoRoot 'modules' 'Rulebook.Action.psd1') -Force + $script:utf8 = [System.Text.UTF8Encoding]::new($false) + + function Get-TestFile { + return Join-Path $TestDrive ('{0}.txt' -f [guid]::NewGuid().ToString('n').Substring(0, 12)) + } + + function Invoke-Annotation { + # Runs Add-Annotation; returns the console lines (information stream). + param([hashtable]$Parameters) + return @(Add-Annotation @Parameters 6>&1 | ForEach-Object { [string]$_.MessageData }) + } +} + +AfterAll { + Remove-Module Rulebook.Action -ErrorAction SilentlyContinue +} + +Describe 'Rulebook.Action manifest' { + It 'exports exactly the ten helpers' { + $module = Get-Module Rulebook.Action + @($module.ExportedFunctions.Keys | Sort-Object) | Should-BeCollection @('Add-Annotation', 'Add-Failure', 'ConvertTo-SingleLine', 'Format-AnnotationText', 'Format-TableCell', 'Limit-SummaryText', 'New-ActionContext', 'Resolve-ActionPath', 'Write-ActionOutput', 'Write-Text') + } + + It 'imports no other engine module' { + $text = Get-Content -LiteralPath (Join-Path $repoRoot 'modules' 'Rulebook.Action.psm1') -Raw + $text | Should-NotMatchString '(?m)^\s*Import-Module' + } +} + +Describe 'Format-AnnotationText' { + It 'escapes %, CR and LF in a message' { + Format-AnnotationText "100% done`r`nnext" | Should-Be '100%25 done%0D%0Anext' + } + + It 'keeps : and , in a message' { + Format-AnnotationText 'C3: a, b' | Should-Be 'C3: a, b' + } + + It 'also escapes : and , in a property value' { + Format-AnnotationText "a:b,c%`n" -Property | Should-Be 'a%3Ab%2Cc%25%0A' + } + + It 'escapes % before the other characters, so an escape is not escaped twice' { + Format-AnnotationText '%0A' | Should-Be '%250A' + } + + It 'returns an empty string for $null' { + Format-AnnotationText $null | Should-Be '' + Format-AnnotationText $null -Property | Should-Be '' + } +} + +Describe 'ConvertTo-SingleLine and Format-TableCell' { + It 'turns CR and LF into spaces without table escaping' { + ConvertTo-SingleLine "a|b`r`nc" | Should-Be 'a|b c' + ConvertTo-SingleLine $null | Should-Be '' + } + + It 'escapes | and turns CR and LF into spaces in a table cell' { + Format-TableCell "a|b`nc" | Should-Be 'a\|b c' + Format-TableCell $null | Should-Be '' + } +} + +Describe 'Add-Annotation' { + BeforeEach { + $script:ctx = New-ActionContext -Title 'Publish' + } + + It 'writes the line with the title of the context and collects it' { + $lines = Invoke-Annotation @{ Context = $ctx; Message = "boom`nline 2" } + $lines | Should-BeCollection @('::error title=Publish::boom%0Aline 2') + $ctx.Annotations.ToArray() | Should-BeCollection @('::error title=Publish::boom%0Aline 2') + } + + It 'writes file= before the title, both escaped as properties' { + $lines = Invoke-Annotation @{ Context = $ctx; Command = 'warning'; File = 'levels/a,b.json'; Title = 'C3: x'; Message = 'm' } + $lines | Should-BeCollection @('::warning file=levels/a%2Cb.json,title=C3%3A x::m') + } + + It 'leaves file= out for an empty or $null file' { + $lines = @(Invoke-Annotation @{ Context = $ctx; File = ''; Message = 'a' }) + @(Invoke-Annotation @{ Context = $ctx; File = $null; Message = 'b' }) + $lines | Should-BeCollection @('::error title=Publish::a', '::error title=Publish::b') + } + + It 'collects the unescaped message of an error, not of a warning or notice' { + $null = Invoke-Annotation @{ Context = $ctx; Message = "first`nsecond" } + $null = Invoke-Annotation @{ Context = $ctx; Command = 'warning'; Message = 'w' } + $null = Invoke-Annotation @{ Context = $ctx; Command = 'notice'; Message = 'n' } + $ctx.ErrorMessages.ToArray() | Should-BeCollection @("first`nsecond") + $ctx.Annotations.Count | Should-Be 3 + } + + It 'writes an explicit empty title as an empty title' { + $lines = Invoke-Annotation @{ Context = $ctx; Title = ''; Command = 'notice'; Message = 'm' } + $lines | Should-BeCollection @('::notice title=::m') + } +} + +Describe 'Add-Failure' { + It 'keeps the first kind' { + $ctx = New-ActionContext -Title 'Scan' + $ctx.Failure | Should-BeNull + Add-Failure -Context $ctx -Kind 'token' + Add-Failure -Context $ctx -Kind 'error' + $ctx.Failure | Should-Be 'token' + } +} + +Describe 'Write-Text' { + It 'appends, writes CRLF as LF and writes no BOM' { + $path = Get-TestFile + Write-Text -Path $path -Text "a`r`nb`n" + Write-Text -Path $path -Text "c`r`n" + $bytes = [System.IO.File]::ReadAllBytes($path) + $bytes[0] | Should-Be ([byte][char]'a') + $utf8.GetString($bytes) | Should-Be "a`nb`nc`n" + } + + It 'writes nothing for an empty or $null path' { + Write-Text -Path '' -Text 'x' + Write-Text -Path $null -Text 'x' + } + + It 'resolves a relative path against the PowerShell location' { + $folder = Join-Path $TestDrive 'relative-write' + $null = New-Item -ItemType Directory -Path $folder -Force + Push-Location -LiteralPath $folder + try { + Write-Text -Path 'out.txt' -Text 'x' + } finally { + Pop-Location + } + Get-Content -LiteralPath (Join-Path $folder 'out.txt') -Raw | Should-Be 'x' + } +} + +Describe 'Resolve-ActionPath' { + It 'resolves a relative path against the PowerShell location, not the process directory' { + $folder = Join-Path $TestDrive 'relative-resolve' + $null = New-Item -ItemType Directory -Path $folder -Force + Push-Location -LiteralPath $folder + try { + $resolved = Resolve-ActionPath (Join-Path 'sub' 'file.md') + } finally { + Pop-Location + } + $resolved | Should-Be (Join-Path ((Resolve-Path -LiteralPath $folder).ProviderPath) 'sub' 'file.md') + } + + It 'returns an empty or $null path as it is' { + Resolve-ActionPath '' | Should-Be '' + Resolve-ActionPath $null | Should-Be '' + } +} + +Describe 'Write-ActionOutput' { + It 'writes key=value lines in the order of the dictionary, $null as empty' { + $path = Get-TestFile + Write-ActionOutput -Path $path -Outputs ([ordered]@{ zeta = 1; alpha = 'two'; failure = $null; flag = $false.ToString().ToLowerInvariant() }) + Get-Content -LiteralPath $path -Raw | Should-Be "zeta=1`nalpha=two`nfailure=`nflag=false`n" + } + + It 'defaults to GITHUB_OUTPUT and writes nothing without it' { + $saved = $env:GITHUB_OUTPUT + try { + $path = Get-TestFile + $env:GITHUB_OUTPUT = $path + Write-ActionOutput -Outputs ([ordered]@{ errors = 0 }) + Get-Content -LiteralPath $path -Raw | Should-Be "errors=0`n" + $env:GITHUB_OUTPUT = $null + Write-ActionOutput -Outputs ([ordered]@{ errors = 0 }) + } finally { + $env:GITHUB_OUTPUT = $saved + } + } + + It 'writes nothing for an empty dictionary' { + $path = Get-TestFile + Write-ActionOutput -Path $path -Outputs ([ordered]@{}) + Test-Path -LiteralPath $path | Should-BeFalse + } +} + +Describe 'Limit-SummaryText' { + BeforeAll { + $script:text = "## Title`n`nintro`n`n``````powershell`n" + (@(1..200 | ForEach-Object { "line $_" }) -join "`n") + "`n```````n`nafter`n" + } + + It 'cuts a summary at a line boundary, closes an open fence and adds the footer' { + $cut = Limit-SummaryText -Text $text -MaxBytes 400 -Footer 'The summary was cut at 0 KiB; the full lists are in the job log.' + [System.Text.Encoding]::UTF8.GetByteCount($cut) | Should-BeLessThanOrEqual 400 + $cut | Should-MatchString '(?s)\A## Title\n' + $cut | Should-MatchString '\n```\n\n_The summary was cut at 0 KiB; the full lists are in the job log\._\n\z' + @($cut.Split("`n") | Where-Object { $_ -match '^```' }).Count % 2 | Should-Be 0 + } + + It 'returns text within the limit as it is' { + Limit-SummaryText -Text $text -MaxBytes 1000000 -Footer 'x' | Should-Be $text + } + + It 'keeps a summary over the 900 KiB the actions pass below it' { + $row = '| C3 | error | `levels/a.json` | ALC0001 | ' + ('x' * 100) + ' |' + $big = "## Rulebook validation`n`n" + (@(1..10000 | ForEach-Object { $row }) -join "`n") + "`n" + [System.Text.Encoding]::UTF8.GetByteCount($big) | Should-BeGreaterThan 900KB + $cut = Limit-SummaryText -Text $big -MaxBytes 900KB -Footer 'The summary was truncated at 900 KiB; the -JsonPath file and the annotations above have the findings.' + [System.Text.Encoding]::UTF8.GetByteCount($cut) | Should-BeLessThanOrEqual 900KB + $cut | Should-MatchString '\|\n\n_The summary was truncated at 900 KiB; the -JsonPath file and the annotations above have the findings\._\n\z' + } +} diff --git a/tests/Rulebook.Scan.Tests.ps1 b/tests/Rulebook.Scan.Tests.ps1 index fa5a5f1..0da24ee 100644 --- a/tests/Rulebook.Scan.Tests.ps1 +++ b/tests/Rulebook.Scan.Tests.ps1 @@ -81,7 +81,7 @@ BeforeAll { AfterAll { $env:GITHUB_SERVER_URL = $script:savedServerUrl $env:GITHUB_API_URL = $script:savedApiUrl - Remove-Module Rulebook.Scan, Rulebook.Quarantine, Rulebook.Extract, Rulebook.Catalog, Rulebook.NuGet, Rulebook.Update, Rulebook.Template, Rulebook.GitHub, Rulebook.Validate, Rulebook.Generate -ErrorAction SilentlyContinue + Remove-Module Rulebook.Scan, Rulebook.Quarantine, Rulebook.Extract, Rulebook.Catalog, Rulebook.NuGet, Rulebook.Update, Rulebook.Template, Rulebook.GitHub, Rulebook.Validate, Rulebook.Generate, Rulebook.Action -ErrorAction SilentlyContinue } Describe 'Get-RulebookScanPlan, first run' { diff --git a/tests/Rulebook.Update.Tests.ps1 b/tests/Rulebook.Update.Tests.ps1 index c7622da..b57eba2 100644 --- a/tests/Rulebook.Update.Tests.ps1 +++ b/tests/Rulebook.Update.Tests.ps1 @@ -87,7 +87,7 @@ BeforeAll { AfterAll { $env:GITHUB_API_URL = $script:savedApiUrl $env:GITHUB_SERVER_URL = $script:savedServerUrl - Remove-Module Rulebook.Update, Rulebook.GitHub, Rulebook.Template, Rulebook.Validate, Rulebook.Generate -ErrorAction SilentlyContinue + Remove-Module Rulebook.Update, Rulebook.GitHub, Rulebook.Template, Rulebook.Validate, Rulebook.Generate, Rulebook.Action -ErrorAction SilentlyContinue } Describe 'Fixture consistency' { @@ -1077,16 +1077,6 @@ Describe 'ConvertTo-UpdatePullRequestBody and ConvertTo-UpdateSummary' { $summary | Should-MatchString '(?m)^## Release notes\n\n### v1\.1$' } - It 'cuts a summary at a line boundary, closes an open fence and adds the footer' { - $text = "## Title`n`nintro`n`n``````powershell`n" + (@(1..200 | ForEach-Object { "line $_" }) -join "`n") + "`n```````n`nafter`n" - $cut = Limit-SummaryText -Text $text -MaxBytes 400 -Footer 'The summary was cut at 0 KiB; the full lists are in the job log.' - [System.Text.Encoding]::UTF8.GetByteCount($cut) | Should-BeLessThanOrEqual 400 - $cut | Should-MatchString '(?s)\A## Title\n' - $cut | Should-MatchString '\n```\n\n_The summary was cut at 0 KiB; the full lists are in the job log\._\n\z' - @($cut.Split("`n") | Where-Object { $_ -match '^```' }).Count % 2 | Should-Be 0 - Limit-SummaryText -Text $text -MaxBytes 1000000 -Footer 'x' | Should-Be $text - } - It 'writes the check summary with the change table' { $summary = ConvertTo-UpdateSummary -Plan $plan -Mode check -Message 'Updates available' $summary | Should-MatchString '(?m)^## Template update check$' diff --git a/tests/ScanDiagnostics.Action.Tests.ps1 b/tests/ScanDiagnostics.Action.Tests.ps1 index e100412..2b1d68f 100644 --- a/tests/ScanDiagnostics.Action.Tests.ps1 +++ b/tests/ScanDiagnostics.Action.Tests.ps1 @@ -59,7 +59,7 @@ AfterAll { $env:GITHUB_REPOSITORY = $script:saved.Repository $env:GITHUB_TOKEN = $script:saved.Token $env:RUNNER_TEMP = $script:saved.RunnerTemp - Remove-Module Rulebook.Scan, Rulebook.Quarantine, Rulebook.Extract, Rulebook.Catalog, Rulebook.NuGet, Rulebook.Update, Rulebook.Template, Rulebook.GitHub, Rulebook.Validate, Rulebook.Generate -ErrorAction SilentlyContinue + Remove-Module Rulebook.Scan, Rulebook.Quarantine, Rulebook.Extract, Rulebook.Catalog, Rulebook.NuGet, Rulebook.Update, Rulebook.Template, Rulebook.GitHub, Rulebook.Validate, Rulebook.Generate, Rulebook.Action -ErrorAction SilentlyContinue } Describe 'actions/ScanDiagnostics/action.yaml' { diff --git a/tests/Validate.Action.Tests.ps1 b/tests/Validate.Action.Tests.ps1 index 7cc99f2..cf542f5 100644 --- a/tests/Validate.Action.Tests.ps1 +++ b/tests/Validate.Action.Tests.ps1 @@ -40,7 +40,7 @@ AfterAll { $env:GITHUB_BASE_REF = $script:savedEvent.Base $env:GITHUB_OUTPUT = $script:savedOutput $env:GITHUB_STEP_SUMMARY = $script:savedSummary - Remove-Module Rulebook.Update, Rulebook.GitHub, Rulebook.Template, Rulebook.Validate, Rulebook.Generate -ErrorAction SilentlyContinue + Remove-Module Rulebook.Update, Rulebook.GitHub, Rulebook.Template, Rulebook.Validate, Rulebook.Generate, Rulebook.Action -ErrorAction SilentlyContinue } Describe 'actions/Validate/action.yaml' { From 620b06c5a9ad8a6772b7431867dc44fbd6b0712f Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort <arthur.vandevondervoort@vanroey.be> Date: Thu, 8 Oct 2026 20:50:55 +0200 Subject: [PATCH 2/3] Guard multi-line outputs and resolve summary paths in Write-Text only Review round 1 of #58. Write-ActionOutput writes a value that holds CR or LF in the heredoc form with a random ghadelim_ delimiter, so a value cannot add a forged output line; single-line values are written as before. Write-Text already resolves its path against the PowerShell location, so the four entry scripts no longer pre-resolve -SummaryPath; they still resolve the paths they use elsewhere (JsonPath, WorkPath, StagingPath, ManifestPath). Validate builds its truncation footer from -SummaryLimit (900 KiB at the default, as before). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- actions/CheckForUpdates/CheckForUpdates.ps1 | 2 -- actions/Publish/Publish.ps1 | 2 -- actions/ScanDiagnostics/ScanDiagnostics.ps1 | 2 -- actions/Validate/Validate.ps1 | 5 ++--- modules/Rulebook.Action.psm1 | 17 +++++++++++++---- tests/Rulebook.Action.Tests.ps1 | 7 +++++++ 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/actions/CheckForUpdates/CheckForUpdates.ps1 b/actions/CheckForUpdates/CheckForUpdates.ps1 index e37a664..6b62398 100644 --- a/actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/actions/CheckForUpdates/CheckForUpdates.ps1 @@ -56,8 +56,6 @@ Import-Module (Join-Path $modules 'Rulebook.Action.psd1') -Force $docsUrl = 'https://github.com/ALCops/rulebook/blob/main/docs/ghtokenworkflow.md' -# [System.IO.File] resolves a relative path against the process directory, not the PowerShell location. -$SummaryPath = Resolve-ActionPath $SummaryPath $ownWork = [string]::IsNullOrEmpty($WorkPath) if ($ownWork) { $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } diff --git a/actions/Publish/Publish.ps1 b/actions/Publish/Publish.ps1 index 01d7903..bb96dec 100644 --- a/actions/Publish/Publish.ps1 +++ b/actions/Publish/Publish.ps1 @@ -41,8 +41,6 @@ Import-Module (Join-Path $modules 'Rulebook.Generate.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.Publish.psd1') -Force Import-Module (Join-Path $modules 'Rulebook.Action.psd1') -Force -# [System.IO.File] resolves a relative path against the process directory, not the PowerShell location. -$SummaryPath = Resolve-ActionPath $SummaryPath $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } if ([string]::IsNullOrEmpty($StagingPath)) { $StagingPath = Join-Path $tempRoot 'rulebook-publish' } # The manifest sits next to the staging folder, never inside it, so it is not published. diff --git a/actions/ScanDiagnostics/ScanDiagnostics.ps1 b/actions/ScanDiagnostics/ScanDiagnostics.ps1 index 55ab955..0036864 100644 --- a/actions/ScanDiagnostics/ScanDiagnostics.ps1 +++ b/actions/ScanDiagnostics/ScanDiagnostics.ps1 @@ -57,8 +57,6 @@ Import-Module (Join-Path $modules 'Rulebook.Action.psd1') -Force $docsUrl = 'https://github.com/ALCops/rulebook/blob/main/docs/ghtokenworkflow.md' -# [System.IO.File] resolves a relative path against the process directory, not the PowerShell location. -$SummaryPath = Resolve-ActionPath $SummaryPath $ownWork = [string]::IsNullOrEmpty($WorkPath) if ($ownWork) { $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } diff --git a/actions/Validate/Validate.ps1 b/actions/Validate/Validate.ps1 index ca30dab..6771445 100644 --- a/actions/Validate/Validate.ps1 +++ b/actions/Validate/Validate.ps1 @@ -49,8 +49,7 @@ function Test-GitRef { return $LASTEXITCODE -eq 0 } -# [System.IO.File] resolves a relative path against the process directory, not the PowerShell location. -$SummaryPath = Resolve-ActionPath $SummaryPath +# Test-Rulebook writes -Json with [System.IO.File], which resolves a relative path against the process directory. $JsonPath = Resolve-ActionPath $JsonPath $ctx = New-ActionContext -Title 'Validate' $root = (Resolve-Path -LiteralPath $RepositoryRoot).ProviderPath @@ -170,7 +169,7 @@ if ($null -ne $diffNote) { $summaryText = $summary.ToString().Replace("`r`n", "`n") # The runner caps a step summary at 1 MiB; stay well below it. $summaryLimit = $SummaryLimit -$summaryText = Limit-SummaryText -Text $summaryText -MaxBytes $summaryLimit -Footer 'The summary was truncated at 900 KiB; the -JsonPath file and the annotations above have the findings.' +$summaryText = Limit-SummaryText -Text $summaryText -MaxBytes $summaryLimit -Footer "The summary was truncated at $([math]::Round($summaryLimit / 1KB)) KiB; the -JsonPath file and the annotations above have the findings." Write-Text -Path $SummaryPath -Text $summaryText # 5. Update check (WP07): check mode only, never counted, never failing. diff --git a/modules/Rulebook.Action.psm1 b/modules/Rulebook.Action.psm1 index 54bc14e..177caac 100644 --- a/modules/Rulebook.Action.psm1 +++ b/modules/Rulebook.Action.psm1 @@ -54,7 +54,7 @@ function New-ActionContext { .DESCRIPTION Title is the default annotation title (the action name). Annotations collects every workflow command line Add-Annotation writes, ErrorMessages the message of every error annotation (unescaped), and Failure holds the - first failure kind Add-Failure records ($null until then). + first failure kind Add-Failure records ($null until then). Publish lists ErrorMessages in its failure summary. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an object; changes no state')] [CmdletBinding()] @@ -132,8 +132,9 @@ function Write-ActionOutput { .SYNOPSIS Appends one key=value line per entry of -Outputs, in its order, to -Path (default GITHUB_OUTPUT). .DESCRIPTION - A value is written as PowerShell interpolates it ($null as ''). Nothing is written when -Path is empty or - -Outputs has no entry. Values are expected on one line. + A value is written as PowerShell interpolates it ($null as ''). A value with CR or LF is written in the + heredoc form, key<<ghadelim_<guid>, the value, the delimiter, so it cannot forge another output line. Nothing + is written when -Path is empty or -Outputs has no entry. #> [CmdletBinding()] param( @@ -142,7 +143,15 @@ function Write-ActionOutput { ) if ([string]::IsNullOrEmpty($Path) -or $Outputs.Count -eq 0) { return } $text = [System.Text.StringBuilder]::new() - foreach ($entry in $Outputs.GetEnumerator()) { [void]$text.Append("$($entry.Key)=$($entry.Value)`n") } + foreach ($entry in $Outputs.GetEnumerator()) { + $value = "$($entry.Value)" + if ($value.Contains("`r") -or $value.Contains("`n")) { + $delimiter = 'ghadelim_' + [guid]::NewGuid().ToString('n') + [void]$text.Append("$($entry.Key)<<$delimiter`n$value`n$delimiter`n") + } else { + [void]$text.Append("$($entry.Key)=$value`n") + } + } Write-Text -Path $Path -Text $text.ToString() } diff --git a/tests/Rulebook.Action.Tests.ps1 b/tests/Rulebook.Action.Tests.ps1 index bf9e2d4..f598734 100644 --- a/tests/Rulebook.Action.Tests.ps1 +++ b/tests/Rulebook.Action.Tests.ps1 @@ -167,6 +167,13 @@ Describe 'Write-ActionOutput' { Get-Content -LiteralPath $path -Raw | Should-Be "zeta=1`nalpha=two`nfailure=`nflag=false`n" } + It 'writes a value with CR or LF in the heredoc form and a plain value on one line' { + $path = Get-TestFile + Write-ActionOutput -Path $path -Outputs ([ordered]@{ body = "line 1`nforged=1`r`nline 3"; plain = 'x' }) + $text = Get-Content -LiteralPath $path -Raw + $text | Should-MatchString '\Abody<<(ghadelim_[0-9a-f]{32})\nline 1\nforged=1\nline 3\n\1\nplain=x\n\z' + } + It 'defaults to GITHUB_OUTPUT and writes nothing without it' { $saved = $env:GITHUB_OUTPUT try { From a2a00ccfe2933023b085911b7419c38978ceda5b Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort <arthur.vandevondervoort@vanroey.be> Date: Thu, 8 Oct 2026 21:09:00 +0200 Subject: [PATCH 3/3] Compute the KiB label of the Validate summary footers once Review of #67 found the two footers rounding the limit differently (Round and Floor). One variable, rounded, feeds both; at the default limit both still say 900 KiB. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- actions/Validate/Validate.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/actions/Validate/Validate.ps1 b/actions/Validate/Validate.ps1 index 6771445..39d3690 100644 --- a/actions/Validate/Validate.ps1 +++ b/actions/Validate/Validate.ps1 @@ -169,7 +169,9 @@ if ($null -ne $diffNote) { $summaryText = $summary.ToString().Replace("`r`n", "`n") # The runner caps a step summary at 1 MiB; stay well below it. $summaryLimit = $SummaryLimit -$summaryText = Limit-SummaryText -Text $summaryText -MaxBytes $summaryLimit -Footer "The summary was truncated at $([math]::Round($summaryLimit / 1KB)) KiB; the -JsonPath file and the annotations above have the findings." +# The limit in KiB for the footers of both summary parts. +$limitLabel = [math]::Round($summaryLimit / 1KB) +$summaryText = Limit-SummaryText -Text $summaryText -MaxBytes $summaryLimit -Footer "The summary was truncated at $limitLabel KiB; the -JsonPath file and the annotations above have the findings." Write-Text -Path $SummaryPath -Text $summaryText # 5. Update check (WP07): check mode only, never counted, never failing. @@ -216,7 +218,7 @@ if ($CheckForUpdates) { $updateSummary = $updateSummary.Replace("`r`n", "`n") # Within what the validation summary leaves of the cap: cut at a line boundary, never dropped. $budget = $summaryLimit - [System.Text.Encoding]::UTF8.GetByteCount($summaryText) - $footer = "The update check summary was cut at $([math]::Floor($summaryLimit / 1KB)) KiB; the full lists are in the job log." + $footer = "The update check summary was cut at $limitLabel KiB; the full lists are in the job log." $updateSummary = Limit-SummaryText -Text $updateSummary -MaxBytes ([math]::Max(0, $budget)) -Footer $footer $summaryText += $updateSummary Write-Text -Path $SummaryPath -Text $updateSummary