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..6b62398 100644 --- a/actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/actions/CheckForUpdates/CheckForUpdates.ps1 @@ -52,57 +52,17 @@ $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 $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 +93,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 +109,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 +139,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 +154,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 +162,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 +178,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 +205,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 +230,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..bb96dec 100644 --- a/actions/Publish/Publish.ps1 +++ b/actions/Publish/Publish.ps1 @@ -39,57 +39,17 @@ 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 +Import-Module (Join-Path $modules 'Rulebook.Action.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)) -} - -# [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 $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 +71,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 +110,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 +126,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 +163,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 +176,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 +187,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..0036864 100644 --- a/actions/ScanDiagnostics/ScanDiagnostics.ps1 +++ b/actions/ScanDiagnostics/ScanDiagnostics.ps1 @@ -53,56 +53,17 @@ 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 $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 +95,7 @@ try { try { $null = Get-QuarantinePolicy -Settings $settings } catch { - Add-Failure 'policy' + Add-Failure -Context $ctx -Kind 'policy' throw } @@ -142,11 +103,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 +115,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 +128,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 +137,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 +148,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 +171,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 +184,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 +221,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..39d3690 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) @@ -63,10 +49,9 @@ function Test-GitRef { return $LASTEXITCODE -eq 0 } -# [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 +# 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 $workspace = if ([string]::IsNullOrEmpty($WorkspaceRoot)) { $root } else { (Resolve-Path -LiteralPath $WorkspaceRoot).ProviderPath } $separator = [System.IO.Path]::DirectorySeparatorChar @@ -80,18 +65,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 +169,10 @@ 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)) } +# 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. $updateCheck = $null @@ -211,7 +187,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 +213,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" - } + $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 - 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 +233,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..177caac --- /dev/null +++ b/modules/Rulebook.Action.psm1 @@ -0,0 +1,211 @@ +#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). Publish lists ErrorMessages in its failure summary. + #> + [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 ''). 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( + [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()) { + $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() +} + +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..f598734 --- /dev/null +++ b/tests/Rulebook.Action.Tests.ps1 @@ -0,0 +1,223 @@ +# 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 '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 { + $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' {