diff --git a/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 b/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 new file mode 100644 index 00000000..d34fd3b9 --- /dev/null +++ b/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 @@ -0,0 +1,961 @@ +#Requires -Modules powershell-yaml + +<# + .SYNOPSIS + Inventories caller workflows that use the Process-PSModule reusable workflow. + + .DESCRIPTION + Discovers Process-PSModule caller workflows from either the authenticated GitHub + organization or local Git checkouts. Outputs structured objects and can refresh + JSON and Markdown reports. + + GitHub mode uses the GitHub CLI. Authenticate with GH_TOKEN or gh auth login. + Local mode accepts repository paths or parent directories containing repositories. + + .EXAMPLE + ./.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 ` + -Organization PSModule ` + -TargetReference v8 ` + -JsonPath ./output/process-workflows.json ` + -MarkdownPath ./output/process-workflows.md + + Inventories the PSModule organization through the GitHub API and writes both report formats. + + .EXAMPLE + ./.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 ` + -Path C:\Repos, C:\Users\me\.copilot\repos ` + -MarkdownPath ./output/process-workflows.md + + Recursively discovers local Git repositories below the supplied paths. +#> +[CmdletBinding(DefaultParameterSetName = 'GitHub')] +param( + [Parameter(ParameterSetName = 'GitHub')] + [ValidateNotNullOrEmpty()] + [string] $Organization = 'PSModule', + + [Parameter(ParameterSetName = 'GitHub')] + [ValidateNotNullOrEmpty()] + [string[]] $Repository, + + [Parameter(Mandatory, ParameterSetName = 'Local')] + [ValidateNotNullOrEmpty()] + [string[]] $Path, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $WorkflowReference = 'PSModule/Process-PSModule/.github/workflows/workflow.yml', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $TargetReference, + + [Parameter()] + [string] $JsonPath, + + [Parameter()] + [string] $MarkdownPath, + + [Parameter(ParameterSetName = 'GitHub')] + [switch] $IncludeArchived +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-GhCommand { + <# + .SYNOPSIS + Invokes GitHub CLI arguments and returns their combined output. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string[]] $ArgumentList + ) + + $output = (& gh @ArgumentList 2>&1) -join "`n" + if ($LASTEXITCODE -eq 0) { + return $output + } + + throw "gh $($ArgumentList -join ' ') failed:`n$output" +} + +function ConvertFrom-JsonResponse { + <# + .SYNOPSIS + Converts a possibly empty JSON response into a stable object array. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Content + ) + + if ([string]::IsNullOrWhiteSpace($Content)) { + return [object[]] @() + } + + [object[]] @($Content | ConvertFrom-Json -Depth 100) +} + +function Get-GitHubRepository { + <# + .SYNOPSIS + Gets the repositories included in GitHub inventory discovery. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] + [string] $Owner, + + [Parameter()] + [string[]] $NameWithOwner, + + [Parameter()] + [switch] $IncludeArchivedRepository + ) + + if ($NameWithOwner) { + $repositories = foreach ($name in $NameWithOwner) { + $fullName = if ($name.Contains('/')) { $name } else { "$Owner/$name" } + $response = Invoke-GhCommand -ArgumentList @( + 'repo', 'view', $fullName, + '--json', 'nameWithOwner,defaultBranchRef,isArchived,url' + ) + ConvertFrom-JsonResponse -Content $response + } + } else { + $response = Invoke-GhCommand -ArgumentList @( + 'repo', 'list', $Owner, + '--limit', '1000', + '--json', 'nameWithOwner,defaultBranchRef,isArchived,url' + ) + $repositories = ConvertFrom-JsonResponse -Content $response + } + + [object[]] @($repositories | + Where-Object { $IncludeArchivedRepository -or -not $_.isArchived } | + Sort-Object nameWithOwner) +} + +function Get-GitHubMatchingWorkflowFile { + <# + .SYNOPSIS + Finds and reads default-branch workflow files matching the expected reference. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] + [psobject[]] $RepositoryInfo, + + [Parameter(Mandatory)] + [string] $Owner, + + [Parameter(Mandatory)] + [string] $ExpectedReference + ) + + $query = "$ExpectedReference org:$Owner path:.github/workflows" + $response = Invoke-GhCommand -ArgumentList @( + 'api', + '--paginate', + '--slurp', + '-X', + 'GET', + 'search/code', + '-f', + "q=$query", + '-f', + 'per_page=100' + ) + $pages = @($response | ConvertFrom-Json -Depth 100) + if ($pages.incomplete_results -contains $true) { + throw "GitHub code search returned incomplete results for [$query]." + } + + $searchResults = @($pages | ForEach-Object { $_.items }) + if (-not $searchResults) { + throw "GitHub code search returned no matches for [$query]." + } + + $expectedResultCount = @($pages | Select-Object -ExpandProperty total_count -Unique) + if ($expectedResultCount.Count -ne 1 -or $searchResults.Count -ne $expectedResultCount[0]) { + throw "GitHub code search returned [$($searchResults.Count)] of [$($expectedResultCount -join ', ')] results for [$query]." + } + + $repositoryByName = @{} + foreach ($item in $RepositoryInfo) { + $repositoryByName[$item.nameWithOwner] = $item + } + + [object[]] @($searchResults | + Where-Object { $repositoryByName.ContainsKey($_.repository.full_name) } | + Sort-Object { $_.repository.full_name }, path -Unique | + ForEach-Object { + $match = $_ + $repository = $repositoryByName[$match.repository.full_name] + $branch = [uri]::EscapeDataString($repository.defaultBranchRef.name) + [pscustomobject]@{ + Repository = $repository.nameWithOwner + DefaultBranch = $repository.defaultBranchRef.name + Archived = $repository.isArchived + RepositoryUrl = $repository.url + WorkflowPath = $match.path + WorkflowUrl = $match.html_url + SearchQuery = $query + Content = Invoke-GhCommand -ArgumentList @( + 'api', + "repos/$($repository.nameWithOwner)/contents/$($match.path)?ref=$branch", + '-H', + 'Accept: application/vnd.github.raw+json' + ) + } + }) +} + +function Get-LocalRepositoryRoot { + <# + .SYNOPSIS + Discovers unique Git repository roots below the supplied paths. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [string[]] $InputPath + ) + + $roots = foreach ($candidate in $InputPath) { + $resolvedPath = (Resolve-Path -LiteralPath $candidate).Path + if (Test-Path -LiteralPath (Join-Path $resolvedPath '.git')) { + $resolvedPath + continue + } + + Get-ChildItem -LiteralPath $resolvedPath -Filter '.git' -Force -Recurse | + ForEach-Object { + if ($_.PSIsContainer) { + $_.Parent.FullName + } else { + $_.DirectoryName + } + } + } + + [string[]] @($roots | Sort-Object -Unique) +} + +function Get-LocalRepositoryName { + <# + .SYNOPSIS + Resolves a repository name from its origin URL or local directory. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string] $RepositoryRoot + ) + + $remote = (& git -C $RepositoryRoot config --get remote.origin.url 2>$null) -join '' + if ($LASTEXITCODE -eq 0 -and $remote -match '(?[^/:]+)/(?[^/]+?)(?:\.git)?$') { + return "$($Matches.owner)/$($Matches.repo)" + } + + Split-Path -Path $RepositoryRoot -Leaf +} + +function Get-LocalDefaultBranch { + <# + .SYNOPSIS + Resolves the Git ref used as the local repository's default branch. + #> + [CmdletBinding()] + [OutputType([psobject])] + param( + [Parameter(Mandatory)] + [string] $RepositoryRoot + ) + + $originHead = (& git -C $RepositoryRoot symbolic-ref refs/remotes/origin/HEAD --short 2>$null) -join '' + if ($LASTEXITCODE -eq 0 -and $originHead) { + return [pscustomobject]@{ + Name = $originHead -replace '^origin/', '' + Ref = $originHead + } + } + + & git -C $RepositoryRoot show-ref --verify --quiet refs/heads/main + if ($LASTEXITCODE -eq 0) { + return [pscustomobject]@{ + Name = 'main' + Ref = 'main' + } + } + + $branch = (& git -C $RepositoryRoot branch --show-current 2>$null) -join '' + if ($LASTEXITCODE -eq 0 -and $branch) { + return [pscustomobject]@{ + Name = $branch + Ref = $branch + } + } + + throw "Could not determine a default or current branch for local repository [$RepositoryRoot]." +} + +function Get-LocalWorkflowFile { + <# + .SYNOPSIS + Reads workflow files from each local repository's default-branch Git object. + #> + [CmdletBinding()] + [OutputType([psobject[]])] + param( + [Parameter(Mandatory)] + [string[]] $InputPath + ) + + $seenRepositories = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($repositoryRoot in Get-LocalRepositoryRoot -InputPath $InputPath) { + $repositoryName = Get-LocalRepositoryName -RepositoryRoot $repositoryRoot + if (-not $seenRepositories.Add($repositoryName)) { + continue + } + + $defaultBranch = Get-LocalDefaultBranch -RepositoryRoot $repositoryRoot + $workflowPaths = @( + (& git -C $repositoryRoot ls-tree -r --name-only $defaultBranch.Ref -- '.github/workflows' 2>&1) -join "`n" + ) + if ($LASTEXITCODE -ne 0) { + throw "Could not list workflows from [$repositoryName] at [$($defaultBranch.Ref)]:`n$workflowPaths" + } + + @($workflowPaths -split '\r?\n') | + Where-Object { [IO.Path]::GetExtension($_) -in @('.yml', '.yaml') } | + ForEach-Object { + $workflowPath = $_ + $content = (& git -C $repositoryRoot show "$($defaultBranch.Ref):$workflowPath" 2>&1) -join "`n" + if ($LASTEXITCODE -ne 0) { + throw "Could not read [$workflowPath] from [$repositoryName] at [$($defaultBranch.Ref)]:`n$content" + } + + [pscustomobject]@{ + Repository = $repositoryName + DefaultBranch = $defaultBranch.Name + Archived = $false + RepositoryUrl = $null + WorkflowPath = $workflowPath + WorkflowUrl = $null + SearchQuery = $null + Content = $content + } + } + } +} + +function Get-MapKey { + <# + .SYNOPSIS + Gets normalized string keys from dictionary-like YAML values. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter()] + [AllowNull()] + [object] $Map + ) + + if ($null -eq $Map) { + return [string[]] @() + } + + if ($Map -is [Collections.IDictionary]) { + return [string[]] @($Map.Keys | ForEach-Object { "$_" }) + } + + [string[]] @($Map.PSObject.Properties.Name) +} + +function Get-MapValue { + <# + .SYNOPSIS + Gets a named value from dictionary-like YAML values. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter()] + [AllowNull()] + [object] $Map, + + [Parameter(Mandatory)] + [string] $Name + ) + + if ($null -eq $Map) { + return $null + } + + if ($Map -is [Collections.IDictionary]) { + return $Map[$Name] + } + + $property = $Map.PSObject.Properties[$Name] + if ($null -eq $property) { + return $null + } + + $property.Value +} + +function ConvertTo-TriggerMap { + <# + .SYNOPSIS + Normalizes mapping, scalar, and list workflow trigger syntax. + #> + [CmdletBinding()] + [OutputType([Collections.IDictionary], [Collections.Specialized.OrderedDictionary])] + param( + [Parameter()] + [AllowNull()] + [object] $Trigger + ) + + if ($null -eq $Trigger) { + return [ordered]@{} + } + + if ($Trigger -is [Collections.IDictionary]) { + return $Trigger + } + + $result = [ordered]@{} + if ($Trigger -is [string]) { + $result[$Trigger] = $null + return $result + } + + if ($Trigger -is [Collections.IEnumerable]) { + foreach ($eventName in $Trigger) { + if ($eventName -isnot [string]) { + throw "Unsupported workflow trigger value type [$($eventName.GetType().FullName)]." + } + $result[$eventName] = $null + } + return $result + } + + throw "Unsupported workflow trigger type [$($Trigger.GetType().FullName)]." +} + +function ConvertTo-StringMap { + <# + .SYNOPSIS + Converts dictionary-like values into an ordered string map. + #> + [CmdletBinding()] + [OutputType([Collections.Specialized.OrderedDictionary])] + param( + [Parameter()] + [AllowNull()] + [object] $Map + ) + + $result = [ordered]@{} + foreach ($key in Get-MapKey -Map $Map) { + $value = Get-MapValue -Map $Map -Name $key + $result[$key] = if ($null -eq $value) { $null } else { "$value" } + } + $result +} + +function ConvertTo-StringArray { + <# + .SYNOPSIS + Converts a possibly empty YAML value into a string array. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return [string[]] @() + } + + [string[]] @($Value | ForEach-Object { "$_" }) +} + +function ConvertTo-PermissionValue { + <# + .SYNOPSIS + Normalizes scalar and mapping workflow permission syntax. + #> + [CmdletBinding()] + [OutputType([string], [Collections.Specialized.OrderedDictionary])] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return [ordered]@{} + } + + if ($Value -is [string]) { + return $Value + } + + if ($Value -is [Collections.IDictionary]) { + return ConvertTo-StringMap -Map $Value + } + + throw "Unsupported workflow permissions type [$($Value.GetType().FullName)]." +} + +function Get-WorkflowInventoryItem { + <# + .SYNOPSIS + Parses a workflow file into a normalized inventory record. + #> + [CmdletBinding()] + [OutputType([psobject])] + param( + [Parameter(Mandatory)] + [psobject] $WorkflowFile, + + [Parameter(Mandatory)] + [string] $ExpectedReference, + + [Parameter()] + [string] $ExpectedTargetReference + ) + + if ($WorkflowFile.Content -notmatch [regex]::Escape($ExpectedReference)) { + return + } + + try { + $workflow = ConvertFrom-Yaml -Yaml $WorkflowFile.Content -Ordered + } catch { + return [pscustomobject]@{ + Repository = $WorkflowFile.Repository + DefaultBranch = $WorkflowFile.DefaultBranch + Archived = $WorkflowFile.Archived + RepositoryUrl = $WorkflowFile.RepositoryUrl + WorkflowPath = $WorkflowFile.WorkflowPath + WorkflowUrl = $WorkflowFile.WorkflowUrl + SearchQuery = $WorkflowFile.SearchQuery + Status = 'ParseError' + Error = $_.Exception.Message + } + } + + $jobs = Get-MapValue -Map $workflow -Name 'jobs' + $processJobs = foreach ($jobName in Get-MapKey -Map $jobs) { + $job = Get-MapValue -Map $jobs -Name $jobName + $uses = Get-MapValue -Map $job -Name 'uses' + if ("$uses" -notlike "$ExpectedReference@*") { + continue + } + + $secrets = Get-MapValue -Map $job -Name 'secrets' + $secretMode = if ($secrets -is [string]) { "$secrets" } elseif ($null -eq $secrets) { 'none' } else { 'explicit' } + [pscustomobject]@{ + Name = $jobName + Uses = "$uses" + Reference = "$uses".Substring("$ExpectedReference@".Length) + MatchesTarget = if ($ExpectedTargetReference) { + "$uses".Substring("$ExpectedReference@".Length) -ceq $ExpectedTargetReference + } else { + $null + } + Inputs = ConvertTo-StringMap -Map (Get-MapValue -Map $job -Name 'with') + SecretMode = $secretMode + SecretMappings = if ($secretMode -eq 'explicit') { + ConvertTo-StringMap -Map $secrets + } else { + [ordered]@{} + } + Environment = Get-MapValue -Map $job -Name 'environment' + Condition = Get-MapValue -Map $job -Name 'if' + } + } + + if (-not $processJobs) { + return + } + + try { + $trigger = ConvertTo-TriggerMap -Trigger (Get-MapValue -Map $workflow -Name 'on') + } catch { + return [pscustomobject]@{ + Repository = $WorkflowFile.Repository + DefaultBranch = $WorkflowFile.DefaultBranch + Archived = $WorkflowFile.Archived + RepositoryUrl = $WorkflowFile.RepositoryUrl + WorkflowPath = $WorkflowFile.WorkflowPath + WorkflowUrl = $WorkflowFile.WorkflowUrl + SearchQuery = $WorkflowFile.SearchQuery + Status = 'ParseError' + Error = $_.Exception.Message + } + } + try { + $pullRequest = Get-MapValue -Map $trigger -Name 'pull_request' + $push = Get-MapValue -Map $trigger -Name 'push' + $schedule = Get-MapValue -Map $trigger -Name 'schedule' + $concurrency = Get-MapValue -Map $workflow -Name 'concurrency' + if ($concurrency -is [string]) { + $concurrencyGroup = $concurrency + $cancelInProgress = $null + } elseif ($null -eq $concurrency -or $concurrency -is [Collections.IDictionary]) { + $concurrencyGroup = Get-MapValue -Map $concurrency -Name 'group' + $cancelInProgress = Get-MapValue -Map $concurrency -Name 'cancel-in-progress' + } else { + throw "Unsupported workflow concurrency type [$($concurrency.GetType().FullName)]." + } + $permissions = ConvertTo-PermissionValue -Value (Get-MapValue -Map $workflow -Name 'permissions') + } catch { + return [pscustomobject]@{ + Repository = $WorkflowFile.Repository + DefaultBranch = $WorkflowFile.DefaultBranch + Archived = $WorkflowFile.Archived + RepositoryUrl = $WorkflowFile.RepositoryUrl + WorkflowPath = $WorkflowFile.WorkflowPath + WorkflowUrl = $WorkflowFile.WorkflowUrl + SearchQuery = $WorkflowFile.SearchQuery + Status = 'ParseError' + Error = $_.Exception.Message + } + } + $allJobNames = Get-MapKey -Map $jobs + $processJobNames = @($processJobs.Name) + + $versionComments = @( + [regex]::Matches( + $WorkflowFile.Content, + "(?m)^\s*uses:\s*$([regex]::Escape($ExpectedReference))@(?[^\s#]+)\s*(?:#\s*(?\S+))?" + ) | ForEach-Object { + [pscustomobject]@{ + Reference = $_.Groups['reference'].Value + Version = $_.Groups['version'].Value + } + } + ) + + [pscustomobject]@{ + Repository = $WorkflowFile.Repository + DefaultBranch = $WorkflowFile.DefaultBranch + Archived = $WorkflowFile.Archived + RepositoryUrl = $WorkflowFile.RepositoryUrl + WorkflowPath = $WorkflowFile.WorkflowPath + WorkflowUrl = $WorkflowFile.WorkflowUrl + SearchQuery = $WorkflowFile.SearchQuery + Status = 'Parsed' + Error = $null + WorkflowName = Get-MapValue -Map $workflow -Name 'name' + RunName = Get-MapValue -Map $workflow -Name 'run-name' + Events = @(Get-MapKey -Map $trigger | Sort-Object) + Schedules = @($schedule | ForEach-Object { Get-MapValue -Map $_ -Name 'cron' }) + PushBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches') + PushBranchesIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches-ignore') + PushPaths = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths') + PushPathsIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths-ignore') + PullRequestBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'branches') + PullRequestTypes = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'types') + ConcurrencyGroup = $concurrencyGroup + CancelInProgress = $cancelInProgress + Permissions = $permissions + ProcessJobs = @($processJobs) + AdditionalJobs = @($allJobNames | Where-Object { $_ -notin $processJobNames }) + VersionComments = $versionComments + TargetReference = $ExpectedTargetReference + MatchesTarget = if ($ExpectedTargetReference) { + @($processJobs | Where-Object { -not $_.MatchesTarget }).Count -eq 0 + } else { + $null + } + } +} + +function ConvertTo-MarkdownCell { + <# + .SYNOPSIS + Escapes a value for safe rendering in a Markdown table cell. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return '' + } + + (($Value -join ', ') -replace '\|', '\|' -replace '\*', '\*' -replace '\r?\n', '
') +} + +function ConvertTo-WorkflowInventoryMarkdown { + <# + .SYNOPSIS + Renders workflow inventory records as a Markdown report. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [psobject[]] $Inventory, + + [Parameter(Mandatory)] + [ValidateSet('GitHub', 'Local')] + [string] $Source, + + [Parameter()] + [string] $TargetReference + ) + + $parsed = @($Inventory | Where-Object Status -eq 'Parsed') + $parseErrors = @($Inventory | Where-Object Status -eq 'ParseError') + $references = @( + $parsed | + ForEach-Object { $_.ProcessJobs.Reference } | + Group-Object | + Sort-Object @{ Expression = 'Count'; Descending = $true }, Name + ) + $versions = @( + $parsed | + ForEach-Object { $_.VersionComments.Version } | + Where-Object { $_ } | + Group-Object | + Sort-Object @{ Expression = 'Count'; Descending = $true }, Name + ) + $eventSets = @( + $parsed | + ForEach-Object { $_.Events -join ', ' } | + Group-Object | + Sort-Object @{ Expression = 'Count'; Descending = $true }, Name + ) + + $lines = [Collections.Generic.List[string]]::new() + $lines.Add('---') + $lines.Add('title: Process-PSModule workflow fleet inventory') + $lines.Add('description: Generated inventory of PSModule repositories that call the Process-PSModule reusable workflow.') + $lines.Add('---') + $lines.Add('') + $lines.Add('# Process-PSModule workflow inventory') + $lines.Add('') + $lines.Add("Generated: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK')") + $lines.Add('') + $lines.Add("- Source: $Source") + $lines.Add("- Workflow files: $($Inventory.Count)") + $lines.Add("- Parsed: $($parsed.Count)") + $lines.Add("- Parse errors: $($parseErrors.Count)") + if ($TargetReference) { + $matchingTarget = @($parsed | Where-Object MatchesTarget).Count + $lines.Add("- Target reference: $TargetReference") + $lines.Add("- Matching target: $matchingTarget/$($Inventory.Count)") + } + $lines.Add('') + $lines.Add('## Reference distribution') + $lines.Add('') + $lines.Add('| Reference | Workflows |') + $lines.Add('| --- | ---: |') + foreach ($group in $references) { + $lines.Add("| $(ConvertTo-MarkdownCell $group.Name) | $($group.Count) |") + } + $lines.Add('') + $lines.Add('## Version distribution') + $lines.Add('') + $lines.Add('| Version comment | Workflows |') + $lines.Add('| --- | ---: |') + foreach ($group in $versions) { + $lines.Add("| $(ConvertTo-MarkdownCell $group.Name) | $($group.Count) |") + } + $lines.Add('') + $lines.Add('## Trigger distribution') + $lines.Add('') + $lines.Add('| Events | Workflows |') + $lines.Add('| --- | ---: |') + foreach ($group in $eventSets) { + $lines.Add("| $(ConvertTo-MarkdownCell $group.Name) | $($group.Count) |") + } + $lines.Add('') + $lines.Add('## Workflow files') + $lines.Add('') + $lines.Add( + '| Repository | File | Name | Run name | Events | Reference | Target | Version | PR types | Push branches |' + + ' Schedule | Concurrency | Cancel | Permissions | Condition | Secrets | Inputs | Extra jobs |' + ) + $lines.Add( + '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |' + ) + + foreach ($item in $Inventory | Sort-Object Repository, WorkflowPath) { + $repositoryCell = if ($item.RepositoryUrl) { + "[$($item.Repository)]($($item.RepositoryUrl))" + } else { + $item.Repository + } + $workflowCell = if ($item.WorkflowUrl) { + "[$($item.WorkflowPath)]($($item.WorkflowUrl))" + } else { + $item.WorkflowPath + } + + if ($item.Status -eq 'ParseError') { + $lines.Add( + "| $(ConvertTo-MarkdownCell $repositoryCell) " + + "| $(ConvertTo-MarkdownCell $workflowCell) | parse error | | | | | | | | | | | | | | | |" + ) + continue + } + + $referencesForItem = @($item.ProcessJobs.Reference | Sort-Object -Unique) + $versionsForItem = @($item.VersionComments.Version | Where-Object { $_ } | Sort-Object -Unique) + $secretSummary = @( + $item.ProcessJobs | ForEach-Object { + if ($_.SecretMode -eq 'explicit') { + "explicit: $(($_.SecretMappings.Keys | Sort-Object) -join ', ')" + } else { + $_.SecretMode + } + } + ) | Sort-Object -Unique + $inputSummary = @( + $item.ProcessJobs | + ForEach-Object { $_.Inputs.Keys } | + Sort-Object -Unique + ) + $conditionSummary = @($item.ProcessJobs.Condition | Where-Object { $_ } | Sort-Object -Unique) + $permissionSummary = if ($item.Permissions -is [string]) { + @($item.Permissions) + } else { + @( + $item.Permissions.GetEnumerator() | + Sort-Object Key | + ForEach-Object { "$($_.Key)=$($_.Value)" } + ) + } + + $lines.Add( + "| $(ConvertTo-MarkdownCell $repositoryCell) " + + "| $(ConvertTo-MarkdownCell $workflowCell) " + + "| $(ConvertTo-MarkdownCell $item.WorkflowName) " + + "| $(ConvertTo-MarkdownCell $item.RunName) " + + "| $(ConvertTo-MarkdownCell $item.Events) " + + "| $(ConvertTo-MarkdownCell $referencesForItem) " + + "| $(ConvertTo-MarkdownCell $item.MatchesTarget) " + + "| $(ConvertTo-MarkdownCell $versionsForItem) " + + "| $(ConvertTo-MarkdownCell $item.PullRequestTypes) " + + "| $(ConvertTo-MarkdownCell $item.PushBranches) " + + "| $(ConvertTo-MarkdownCell $item.Schedules) " + + "| $(ConvertTo-MarkdownCell $item.ConcurrencyGroup) " + + "| $(ConvertTo-MarkdownCell $item.CancelInProgress) " + + "| $(ConvertTo-MarkdownCell $permissionSummary) " + + "| $(ConvertTo-MarkdownCell $conditionSummary) " + + "| $(ConvertTo-MarkdownCell $secretSummary) " + + "| $(ConvertTo-MarkdownCell $inputSummary) " + + "| $(ConvertTo-MarkdownCell $item.AdditionalJobs) |" + ) + } + + if ($parseErrors) { + $lines.Add('') + $lines.Add('## Parse errors') + $lines.Add('') + foreach ($item in $parseErrors) { + $lines.Add("- **$($item.Repository)/$($item.WorkflowPath):** $(ConvertTo-MarkdownCell $item.Error)") + } + } + + $lines -join "`n" +} + +$workflowFiles = if ($PSCmdlet.ParameterSetName -eq 'GitHub') { + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw 'GitHub mode requires the GitHub CLI (gh). Install it and authenticate with gh auth login or GH_TOKEN.' + } + + $repositories = Get-GitHubRepository ` + -Owner $Organization ` + -NameWithOwner $Repository ` + -IncludeArchivedRepository:$IncludeArchived + if (-not $repositories) { + throw "No repositories were found for organization [$Organization]." + } + + @( + Get-GitHubMatchingWorkflowFile ` + -RepositoryInfo $repositories ` + -Owner $Organization ` + -ExpectedReference $WorkflowReference + ) +} else { + @(Get-LocalWorkflowFile -InputPath $Path) +} + +if (-not $workflowFiles) { + throw "No workflow files containing [$WorkflowReference] were discovered." +} + +$inventory = @( + $workflowFiles | + ForEach-Object { + Get-WorkflowInventoryItem ` + -WorkflowFile $_ ` + -ExpectedReference $WorkflowReference ` + -ExpectedTargetReference $TargetReference + } +) + +if (-not $inventory) { + throw "No reusable workflow jobs using [$WorkflowReference] were found in the discovered files." +} + +$parseErrors = @($inventory | Where-Object Status -eq 'ParseError') + +if ($JsonPath) { + $parent = Split-Path -Path $JsonPath -Parent + if ($parent) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + ConvertTo-Json -InputObject @($inventory) -Depth 100 | + Set-Content -LiteralPath $JsonPath -Encoding utf8 +} + +if ($MarkdownPath) { + $parent = Split-Path -Path $MarkdownPath -Parent + if ($parent) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + ConvertTo-WorkflowInventoryMarkdown ` + -Inventory $inventory ` + -Source $PSCmdlet.ParameterSetName ` + -TargetReference $TargetReference | + Set-Content -LiteralPath $MarkdownPath -Encoding utf8 +} + +if ($parseErrors) { + $parseErrorDetails = $parseErrors | + ForEach-Object { "[$($_.Repository)/$($_.WorkflowPath)]: $($_.Error)" } + throw "$($parseErrors.Count) matching workflow file(s) could not be parsed. $($parseErrorDetails -join '; ')" +} + +$inventory diff --git a/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1 b/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1 new file mode 100644 index 00000000..a834d138 --- /dev/null +++ b/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1 @@ -0,0 +1,231 @@ +[CmdletBinding()] +param() + +BeforeAll { + $scriptPath = Join-Path $PSScriptRoot '../Get-ProcessPSModuleWorkflowInventory.ps1' + Test-Path -LiteralPath $scriptPath | Should -BeTrue + $testRoot = Join-Path ([IO.Path]::GetTempPath()) "process-workflow-inventory-$([guid]::NewGuid())" + $repositoryRoot = Join-Path $testRoot 'Example' + $workflowRoot = Join-Path $repositoryRoot '.github/workflows' + & git init --quiet --initial-branch=main $repositoryRoot + & git -C $repositoryRoot config user.email 'inventory-tests@example.invalid' + & git -C $repositoryRoot config user.name 'Inventory Tests' + New-Item -ItemType Directory -Path $workflowRoot -Force | Out-Null + + @' +name: Process-PSModule + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + push: + branches: + - main + pull_request: + branches: + - main + types: + - opened + - synchronize + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + Process-PSModule: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + with: + Debug: true + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} +'@ | Set-Content -LiteralPath (Join-Path $workflowRoot 'Process-PSModule.yml') + + @' +name: Unrelated +on: + workflow_dispatch: +jobs: + Test: + runs-on: ubuntu-latest + steps: + - run: echo test +'@ | Set-Content -LiteralPath (Join-Path $workflowRoot 'Unrelated.yml') + + & git -C $repositoryRoot add . + & git -C $repositoryRoot commit --quiet -m 'Add test workflows' + & git -C $repositoryRoot update-ref refs/remotes/origin/main HEAD + & git -C $repositoryRoot symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main + & git -C $repositoryRoot switch --quiet -c feature + $featureContent = Get-Content -LiteralPath (Join-Path $workflowRoot 'Process-PSModule.yml') -Raw + $featureContent.Replace( + 'workflow.yml@v8', + 'workflow.yml@v9' + ) | + Set-Content -LiteralPath (Join-Path $workflowRoot 'Process-PSModule.yml') +} + +AfterAll { + if (Test-Path -LiteralPath $testRoot) { + Get-ChildItem -LiteralPath $testRoot -Recurse -Force | + ForEach-Object { $_.Attributes = [IO.FileAttributes]::Normal } + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} + +Describe 'Get-ProcessPSModuleWorkflowInventory' { + It 'inventories matching local workflows and their compatibility dimensions' { + $result = @( + & $scriptPath ` + -Path $testRoot ` + -TargetReference 'v8' + ) + + $result.Count | Should -Be 1 + $result[0].Repository | Should -Be 'Example' + $result[0].WorkflowName | Should -Be 'Process-PSModule' + $result[0].Events | Should -Be @('pull_request', 'push', 'schedule', 'workflow_dispatch') + $result[0].PushBranches | Should -Be @('main') + $result[0].PullRequestTypes | Should -Be @('opened', 'synchronize') + $result[0].ConcurrencyGroup | Should -Be '${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}' + $result[0].CancelInProgress | Should -BeFalse + $result[0].ProcessJobs[0].Reference | Should -Be 'v8' + $result[0].ProcessJobs[0].MatchesTarget | Should -BeTrue + $result[0].MatchesTarget | Should -BeTrue + $result[0].ProcessJobs[0].Condition | Should -Match 'head.repo.full_name' + $result[0].ProcessJobs[0].Inputs.Keys | Should -Contain 'Debug' + $result[0].ProcessJobs[0].SecretMappings.Keys | Should -Be @( + 'PSGALLERY_API_KEY' + 'GitHubAppClientId' + 'GitHubAppPrivateKey' + ) + } + + It 'compares target references case-sensitively' { + $result = @(& $scriptPath -Path $repositoryRoot -TargetReference 'V8') + + $result[0].ProcessJobs[0].MatchesTarget | Should -BeFalse + $result[0].MatchesTarget | Should -BeFalse + } + + It 'reads the remote default branch instead of feature-worktree changes' { + $result = @(& $scriptPath -Path $repositoryRoot) + + $result[0].DefaultBranch | Should -Be 'main' + $result[0].ProcessJobs[0].Reference | Should -Be 'v8' + } + + It 'writes JSON and Markdown refresh artifacts' { + $jsonPath = Join-Path $testRoot 'inventory.json' + $markdownPath = Join-Path $testRoot 'inventory.md' + + & $scriptPath ` + -Path $repositoryRoot ` + -TargetReference 'v8' ` + -JsonPath $jsonPath ` + -MarkdownPath $markdownPath | + Out-Null + + Test-Path -LiteralPath $jsonPath | Should -BeTrue + Test-Path -LiteralPath $markdownPath | Should -BeTrue + (Get-Content -LiteralPath $jsonPath -Raw).TrimStart() | Should -Match '^\[' + Get-Content -LiteralPath $markdownPath -Raw | Should -Match 'Example' + Get-Content -LiteralPath $markdownPath -Raw | Should -Match 'v8' + Get-Content -LiteralPath $markdownPath -Raw | Should -Match 'Matching target: 1/1' + Get-Content -LiteralPath $markdownPath -Raw | Should -Match '0 0 \\\* \\\* \\\*' + } + + It 'reports and fails closed for a matching malformed workflow' { + $malformedRoot = Join-Path $testRoot 'Malformed' + $malformedWorkflowRoot = Join-Path $malformedRoot '.github/workflows' + $jsonPath = Join-Path $malformedRoot 'output/inventory.json' + $markdownPath = Join-Path $malformedRoot 'output/inventory.md' + & git init --quiet --initial-branch=main $malformedRoot + & git -C $malformedRoot config user.email 'inventory-tests@example.invalid' + & git -C $malformedRoot config user.name 'Inventory Tests' + New-Item -ItemType Directory -Path $malformedWorkflowRoot -Force | Out-Null + @' +name: Broken +jobs: + Process: + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + invalid: [ +'@ | Set-Content -LiteralPath (Join-Path $malformedWorkflowRoot 'Process.yml') + & git -C $malformedRoot add . + & git -C $malformedRoot commit --quiet -m 'Add malformed workflow' + + { + & $scriptPath ` + -Path $malformedRoot ` + -TargetReference 'v8' ` + -JsonPath $jsonPath ` + -MarkdownPath $markdownPath | + Out-Null + } | Should -Throw -ExpectedMessage '*1 matching workflow file(s) could not be parsed*' + + $result = @(Get-Content -LiteralPath $jsonPath -Raw | ConvertFrom-Json) + $result.Count | Should -Be 1 + $result[0].Status | Should -Be 'ParseError' + $result[0].Error | Should -Not -BeNullOrEmpty + Get-Content -LiteralPath $markdownPath -Raw | Should -Match 'Parse errors: 1' + Get-Content -LiteralPath $markdownPath -Raw | Should -Match 'Matching target: 0/1' + } + + It 'fails closed when no matching workflow is found' { + $emptyRoot = Join-Path $testRoot 'Empty' + & git init --quiet --initial-branch=main $emptyRoot + & git -C $emptyRoot config user.email 'inventory-tests@example.invalid' + & git -C $emptyRoot config user.name 'Inventory Tests' + New-Item -ItemType Directory -Path (Join-Path $emptyRoot '.github/workflows') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $emptyRoot '.github/workflows/Unrelated.yml') -Value @' +name: Unrelated +on: + workflow_dispatch: +jobs: + Test: + runs-on: ubuntu-latest + steps: + - run: echo test +'@ + & git -C $emptyRoot add . + & git -C $emptyRoot commit --quiet -m 'Add unrelated workflow' + + { & $scriptPath -Path $emptyRoot } | + Should -Throw 'No reusable workflow jobs using*' + } + + It 'normalizes shorthand trigger lists' { + $shorthandRoot = Join-Path $testRoot 'Shorthand' + $shorthandWorkflowRoot = Join-Path $shorthandRoot '.github/workflows' + & git init --quiet --initial-branch=main $shorthandRoot + & git -C $shorthandRoot config user.email 'inventory-tests@example.invalid' + & git -C $shorthandRoot config user.name 'Inventory Tests' + New-Item -ItemType Directory -Path $shorthandWorkflowRoot -Force | Out-Null + Set-Content -LiteralPath (Join-Path $shorthandWorkflowRoot 'Process.yml') -Value @' +name: Shorthand +on: [push, workflow_dispatch] +permissions: read-all +concurrency: process-${{ github.ref }} +jobs: + Process: + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 +'@ + & git -C $shorthandRoot add . + & git -C $shorthandRoot commit --quiet -m 'Add shorthand workflow' + + $result = @(& $scriptPath -Path $shorthandRoot) + + $result[0].Events | Should -Be @('push', 'workflow_dispatch') + $result[0].Permissions | Should -Be 'read-all' + $result[0].ConcurrencyGroup | Should -Be 'process-${{ github.ref }}' + $result[0].CancelInProgress | Should -BeNullOrEmpty + } +} diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index 8e18d7e1..75c6954e 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -181,9 +181,9 @@ Notes: - If using `secrets: inherit` in a caller workflow, remember that GitHub only forwards secrets that already exist by name. It does not assemble a `TestData` JSON payload from individual secrets such as `TEST_USER_PAT`; the caller must still create and pass the `TestData` value explicitly. -- Organization, repository and GitHub *Environment* secrets and variables are supported when they are - visible to the calling job. For environment-scoped values, set `environment:` on the calling job and - explicitly include those values in `TestData`; they are not exposed automatically. +- Organization and repository secrets and variables are supported when they are visible to the calling job. + GitHub Environment secrets are not supported by this caller contract because a job that calls a reusable + workflow cannot declare `environment:`. ## Important file change detection diff --git a/docs/content/reference/process-workflow-fleet-inventory.md b/docs/content/reference/process-workflow-fleet-inventory.md new file mode 100644 index 00000000..48b0131f --- /dev/null +++ b/docs/content/reference/process-workflow-fleet-inventory.md @@ -0,0 +1,114 @@ +--- +title: Process-PSModule workflow fleet inventory +description: Generated inventory of PSModule repositories that call the Process-PSModule reusable workflow. +--- + +# Process-PSModule workflow inventory + +Generated: 2026-08-15T14:18:44+02:00 + +- Source: GitHub +- Workflow files: 60 +- Parsed: 60 +- Parse errors: 0 +- Target reference: v8 +- Matching target: 0/60 + +## Reference distribution + +| Reference | Workflows | +| --- | ---: | +| 205d193f34cbbaf9992955c21d842bcf98a1859f | 35 | +| fb1bdb8fefd243292f779d2a856a38db6fe6daf4 | 6 | +| da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 | 5 | +| 11117919e65242d3388727819a751f74ad24ea9e | 4 | +| 688896dc3ef70fb35bd74ae5328e76d5e57fe08a | 4 | +| ce64918acc96dda73eb78f827036b794bfa6fa1a | 3 | +| 1653be8d36607d9535f600278c44789979477813 | 1 | +| 60bdf8a5a4c92c53fcf2a8d23f7d5f5c93e6864e | 1 | +| bf67cd90269ca5ce25cd76b203678907dc2984b4 | 1 | + +## Version distribution + +| Version comment | Workflows | +| --- | ---: | +| v5.4.6 | 35 | +| v6.1.13 | 6 | +| v6.1.4 | 5 | +| v5.5.0 | 4 | +| v6.1.15 | 4 | +| v5.5.7 | 3 | +| v5.4.3 | 1 | +| v6.1.16 | 1 | +| v6.1.19 | 1 | + +## Trigger distribution + +| Events | Workflows | +| --- | ---: | +| pull_request, schedule, workflow_dispatch | 60 | + +## Workflow files + +| Repository | File | Name | Run name | Events | Reference | Target | Version | PR types | Push branches | Schedule | Concurrency | Cancel | Permissions | Condition | Secrets | Inputs | Extra jobs | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| [PSModule/Admin](https://github.com/PSModule/Admin) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Admin/blob/c21efa2de875b25775cad332641b7509db2274b2/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 | False | v6.1.4 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Anthropic](https://github.com/PSModule/Anthropic) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Anthropic/blob/507a3fea65c13965fc550d1eec209db300436e49/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 | False | v6.1.4 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKEY | | | +| [PSModule/Ast](https://github.com/PSModule/Ast) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Ast/blob/769af9815cc948c21860f7392b0538df2065b20e/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 | False | v6.1.4 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Base64](https://github.com/PSModule/Base64) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Base64/blob/f8a7942f4f857b26cdb63199670e480fba5e9d61/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Bluesky](https://github.com/PSModule/Bluesky) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Bluesky/blob/18503ebdf04e401434df2028d75899eb39cdc5db/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/CasingStyle](https://github.com/PSModule/CasingStyle) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/CasingStyle/blob/fc26c170059b8012bdbde34031ffe47ac5cc53ec/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Claude](https://github.com/PSModule/Claude) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Claude/blob/081aae987ee37fc6d1f0142b376f92688dfcf2b0/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKEY | | | +| [PSModule/Confluence](https://github.com/PSModule/Confluence) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Confluence/blob/3e5a057dca611ae1036bdd5731cc7031f2657144/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | fb1bdb8fefd243292f779d2a856a38db6fe6daf4 | False | v6.1.13 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey, TestData | | | +| [PSModule/Context](https://github.com/PSModule/Context) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Context/blob/c80a0a0d97b88f6140ea351962ddf257a4f02b90/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Context7](https://github.com/PSModule/Context7) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Context7/blob/e56e0118107fae5e5cf1385711df8659d67dfde0/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/CurseForge](https://github.com/PSModule/CurseForge) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/CurseForge/blob/41373542ae348296a1ac5b74730946350afedfb0/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 60bdf8a5a4c92c53fcf2a8d23f7d5f5c93e6864e | False | v5.4.3 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKEY | | | +| [PSModule/DateTime](https://github.com/PSModule/DateTime) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/DateTime/blob/17b99ed2aad7b63df512b61267a4f00436897e92/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/DeepSeek](https://github.com/PSModule/DeepSeek) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/DeepSeek/blob/a12f3fe69db3c12cff2b22db017516df10db610e/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Discord](https://github.com/PSModule/Discord) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Discord/blob/560a78d92a33ecdb080c33c1e28f6094da5c5d1e/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Dns](https://github.com/PSModule/Dns) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Dns/blob/58558ff6c0bef552d087360bf5b0d6ad37eecf7f/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Domeneshop](https://github.com/PSModule/Domeneshop) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Domeneshop/blob/2ca6f788c4b68a72c63d6472ae19720bc90cc8b9/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | fb1bdb8fefd243292f779d2a856a38db6fe6daf4 | False | v6.1.13 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKEY | | | +| [PSModule/DynamicParams](https://github.com/PSModule/DynamicParams) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/DynamicParams/blob/092726b82bf38bf8a49bf98cb1349dc6be691fde/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/ElvUI](https://github.com/PSModule/ElvUI) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/ElvUI/blob/892a68211feb229698cfabc6e36abfee56727a30/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 11117919e65242d3388727819a751f74ad24ea9e | False | v5.5.0 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKEY | | | +| [PSModule/Fonts](https://github.com/PSModule/Fonts) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Fonts/blob/d665c51dc39cd4404ea2da2c9f4efb2cf932faa5/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 11117919e65242d3388727819a751f74ad24ea9e | False | v5.5.0 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Gemini](https://github.com/PSModule/Gemini) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Gemini/blob/eadf88ffc09f311d71ff398d36f27f07d188e64c/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKEY | | | +| [PSModule/GitHub](https://github.com/PSModule/GitHub) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/GitHub/blob/3e1f9e7651797091830338ca4c36fb6814bef69f/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | fb1bdb8fefd243292f779d2a856a38db6fe6daf4 | False | v6.1.13 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey, TestData | | | +| [PSModule/GoogleFonts](https://github.com/PSModule/GoogleFonts) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/GoogleFonts/blob/bb329c6912eaa9861d285a7f9915879d72a60567/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 | False | v6.1.4 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey | | | +| [PSModule/GraphQL](https://github.com/PSModule/GraphQL) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/GraphQL/blob/051ed470d8c81170c062a719a4d3a4343e3bd691/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Guid](https://github.com/PSModule/Guid) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Guid/blob/9ea7942021dc21307f774f6c5e63425529501233/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/GZip](https://github.com/PSModule/GZip) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/GZip/blob/4422836a1cb68a8f06884c791ca22be62b808e79/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Hashtable](https://github.com/PSModule/Hashtable) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Hashtable/blob/680c3e8291dfc1696dfad1658b50a0f28cf5a86f/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 | False | v6.1.4 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Hcl](https://github.com/PSModule/Hcl) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Hcl/blob/aa15ae16894757e8d2659cc1b0de42fc922178b3/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 11117919e65242d3388727819a751f74ad24ea9e | False | v5.5.0 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKEY | | | +| [PSModule/IPv4](https://github.com/PSModule/IPv4) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/IPv4/blob/7c63729742f68e985d2216eac79d0ae4d097a756/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/IPv6](https://github.com/PSModule/IPv6) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/IPv6/blob/f53d3e9b081f07557bb6542a916f41f42abbbb55/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Json](https://github.com/PSModule/Json) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Json/blob/4a996b7af4a354a90a2753fc1d00d31e9676fd11/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | ce64918acc96dda73eb78f827036b794bfa6fa1a | False | v5.5.7 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Jwt](https://github.com/PSModule/Jwt) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Jwt/blob/aa64677452cdd4cee62520439f48cdec1ec8621d/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 688896dc3ef70fb35bd74ae5328e76d5e57fe08a | False | v6.1.15 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey, TestData | | | +| [PSModule/LinkedIn](https://github.com/PSModule/LinkedIn) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/LinkedIn/blob/e129428630585da0c27d7c3466f4f3599a7be209/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Lovdata](https://github.com/PSModule/Lovdata) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Lovdata/blob/dfe0f79562fecb8f99ed6e172ad3e4baa35cf821/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | fb1bdb8fefd243292f779d2a856a38db6fe6daf4 | False | v6.1.13 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey | | | +| [PSModule/Lua](https://github.com/PSModule/Lua) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Lua/blob/d532ebfca1c2d042b1a80846af3038ef2ff87386/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 11117919e65242d3388727819a751f74ad24ea9e | False | v5.5.0 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKEY | | | +| [PSModule/Markdown](https://github.com/PSModule/Markdown) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Markdown/blob/3377a6c9bd507a1ad25225fb8c2bb209114215eb/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/MemoryMappedFile](https://github.com/PSModule/MemoryMappedFile) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/MemoryMappedFile/blob/3f5eb7de7484558696ba9a633c7b39e084e2358c/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/NerdFonts](https://github.com/PSModule/NerdFonts) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/NerdFonts/blob/5a9abcb31663bb9d0e7ac58f904efa31a35e80b1/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | bf67cd90269ca5ce25cd76b203678907dc2984b4 | False | v6.1.19 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey | | | +| [PSModule/Net](https://github.com/PSModule/Net) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Net/blob/2facaf6bfa442a92f45b71094952e89999bf1024/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | ce64918acc96dda73eb78f827036b794bfa6fa1a | False | v5.5.7 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Object](https://github.com/PSModule/Object) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Object/blob/c385cfd09ec0ee9483a3123cbfd70cd1c26432cf/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/OpenAI](https://github.com/PSModule/OpenAI) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/OpenAI/blob/d44c807117fda26311a1de0d14ef7fb071767597/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Path](https://github.com/PSModule/Path) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Path/blob/ddf6ba8a813819b6815411deffbe28fa678640a4/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/PowerShellDataFile](https://github.com/PSModule/PowerShellDataFile) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/PowerShellDataFile/blob/d256fea8410477e8b68e6bffa0b8085bb856ab0c/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/PowerShellGallery](https://github.com/PSModule/PowerShellGallery) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/PowerShellGallery/blob/ea0734cf47e6b5957f61d6e9cb53f0ea3a9eb1ad/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/PSCredential](https://github.com/PSModule/PSCredential) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/PSCredential/blob/87cdfd19eceef381faf37bb124d02dba7fa5f3c6/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/PSCustomObject](https://github.com/PSModule/PSCustomObject) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/PSCustomObject/blob/25dd9dc1872f0cc7386e977d713b918738fcbb71/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | ce64918acc96dda73eb78f827036b794bfa6fa1a | False | v5.5.7 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/PSSemVer](https://github.com/PSModule/PSSemVer) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/PSSemVer/blob/1a621b14286331569f4d1ffd4643999c2a2a6ca8/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 688896dc3ef70fb35bd74ae5328e76d5e57fe08a | False | v6.1.15 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey | | | +| [PSModule/PublicIP](https://github.com/PSModule/PublicIP) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/PublicIP/blob/82f70c40a309c9b7390160035a6f836bd29da626/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Retry](https://github.com/PSModule/Retry) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Retry/blob/2ecfcb46c3204a167a011202a663639996fa1895/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Sodium](https://github.com/PSModule/Sodium) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Sodium/blob/3d96d48c63758298616ab80215f59d4fed7cb7d3/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | fb1bdb8fefd243292f779d2a856a38db6fe6daf4 | False | v6.1.13 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Telemetry](https://github.com/PSModule/Telemetry) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Telemetry/blob/e85cf2df6611ba056eafa46610b17c06674a71de/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Template-PSModule](https://github.com/PSModule/Template-PSModule) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Template-PSModule/blob/4f525ab008d2d616f2f4e4e20ee2d96d6f76ec67/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | fb1bdb8fefd243292f779d2a856a38db6fe6daf4 | False | v6.1.13 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey | | | +| [PSModule/TimeSpan](https://github.com/PSModule/TimeSpan) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/TimeSpan/blob/8700bcdc8340b52f7eab7b83414def47c36ed3a0/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Tls](https://github.com/PSModule/Tls) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Tls/blob/11ea777e89668c1c4fd6280b312294db01a5ed4d/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Toml](https://github.com/PSModule/Toml) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Toml/blob/f8937f27af3c663c80fc3ca986a3aaa40a191a90/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 688896dc3ef70fb35bd74ae5328e76d5e57fe08a | False | v6.1.15 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey | | | +| [PSModule/Twitch](https://github.com/PSModule/Twitch) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Twitch/blob/9d25274b23af02c45ecc6fb28f682883afa47027/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Uri](https://github.com/PSModule/Uri) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Uri/blob/fe821cf5c13498a092d919f9d3f8a207912afe96/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Utilities](https://github.com/PSModule/Utilities) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Utilities/blob/3583a87c377650bc4eacff70cef4fcb993c2117d/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/WoW](https://github.com/PSModule/WoW) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/WoW/blob/fd432f8ea872d546eabb20488bc5adab15dbaa7f/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 205d193f34cbbaf9992955c21d842bcf98a1859f | False | v5.4.6 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | inherit | | | +| [PSModule/Yaml](https://github.com/PSModule/Yaml) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Yaml/blob/8e37203720719528495da3bef5273c674a4d2e0a/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 688896dc3ef70fb35bd74ae5328e76d5e57fe08a | False | v6.1.15 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey, TestData | ImportantFilePatterns | | +| [PSModule/Yml](https://github.com/PSModule/Yml) | [.github/workflows/Process-PSModule.yml](https://github.com/PSModule/Yml/blob/93d2656563a719d99416b9e13a05e65f6f815498/.github/workflows/Process-PSModule.yml) | Process-PSModule | | pull_request, schedule, workflow_dispatch | 1653be8d36607d9535f600278c44789979477813 | False | v6.1.16 | closed, opened, reopened, synchronize, labeled | | 0 0 \* \* \* | ${{ github.workflow }}-${{ github.ref }} | True | contents=write, id-token=write, pages=write, pull-requests=write, statuses=write | | explicit: APIKey | | | diff --git a/docs/content/reference/process-workflow-fleet-standard.md b/docs/content/reference/process-workflow-fleet-standard.md new file mode 100644 index 00000000..ddde8547 --- /dev/null +++ b/docs/content/reference/process-workflow-fleet-standard.md @@ -0,0 +1,274 @@ +--- +title: Process-PSModule caller workflow candidate +description: Fleet research and candidate required and optional caller workflow elements for Process-PSModule consumers. +--- + +# Process-PSModule caller workflow candidate + +This page records the 2026-08-15 fleet research used to propose a common caller workflow for PowerShell module +repositories. It is a proposal for review before the consumer repositories are changed. + +The generated [workflow fleet inventory](process-workflow-fleet-inventory.md) lists every matching repository and +workflow. Refresh it with: + +```powershell +./.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 ` + -Organization PSModule ` + -TargetReference v8 ` + -JsonPath ./output/process-workflows.json ` + -MarkdownPath ./docs/content/reference/process-workflow-fleet-inventory.md +``` + +GitHub mode uses the authenticated `gh` session, including `GH_TOKEN`. To inventory checked-out repositories without +GitHub discovery, use the local parameter set: + +```powershell +./.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 ` + -Path C:\Repos, C:\Users\me\.copilot\repos ` + -TargetReference v8 ` + -JsonPath ./output/process-workflows.json ` + -MarkdownPath ./output/process-workflows.md +``` + +## Current fleet + +The authenticated organization scan found 60 default-branch caller workflows among 85 active repositories. Every +caller has the same structural baseline: + +| Aspect | Observed value | Coverage | +| --- | --- | ---: | +| File | `.github/workflows/Process-PSModule.yml` | 60/60 | +| Workflow name | `Process-PSModule` | 60/60 | +| Run name | Not set | 60/60 | +| Reusable-workflow job | `Process-PSModule` | 60/60 | +| Reusable-workflow job condition | Not set | 60/60 | +| Events | `workflow_dispatch`, daily `schedule`, `pull_request` | 60/60 | +| Pull-request branch | `main` | 60/60 | +| Pull-request types | `closed`, `opened`, `reopened`, `synchronize`, `labeled` | 60/60 | +| Schedule | `0 0 * * *` | 60/60 | +| Concurrency group | `${{ github.workflow }}-${{ github.ref }}` | 60/60 | +| Cancel in progress | `true` | 60/60 | +| Permissions | `contents`, `pull-requests`, `statuses`, `pages`, and `id-token`: `write` | 60/60 | +| Additional jobs | None | 60/60 | + +The uniform wrapper is a strong starting point, but it predates the two latest breaking releases: + +- `v7.0.0` requires explicit PowerShell Gallery and GitHub App credentials. +- `v8.0.0` moves stable publication to a default-branch `push`, adds `unlabeled` routing, and requires non-cancelling + pull-request-or-ref concurrency. + +No current caller has the `v8.0.0` trigger and concurrency contract. The fleet spans nine older versions: + +| Version | Repositories | +| --- | ---: | +| `v5.4.6` | 35 | +| `v6.1.13` | 6 | +| `v6.1.4` | 5 | +| `v5.5.0` | 4 | +| `v6.1.15` | 4 | +| `v5.5.7` | 3 | +| `v5.4.3` | 1 | +| `v6.1.16` | 1 | +| `v6.1.19` | 1 | + +Secret forwarding is the only widespread caller variation: + +- 41 callers use `secrets: inherit`. +- 15 callers explicitly map only an `APIKey` or `APIKEY` secret. +- Four more callers (`Confluence`, `GitHub`, `Jwt`, and `Yaml`) map the old API key plus `TestData`, for 19 explicit + API-key callers in total. +- `Yaml` is the only caller with a `with:` override (`ImportantFilePatterns`). + +The case difference in the old API key name is historical drift, not a supported option in the current contract. + +## Candidate for discussion + +The current candidate is: + +```yaml +name: Process-PSModule + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + push: + branches: + - main + pull_request: + branches: + - main + types: + - closed + - opened + - reopened + - synchronize + - labeled + - unlabeled + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: {} + +jobs: + Process-PSModule: + permissions: + contents: read + pages: write + id-token: write + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} +``` + +Modules whose local tests require repository-specific data may add the optional secret mapping: + +```yaml + TestData: >- + {"secrets":{"TOKEN":"${{ secrets.TEST_TOKEN }}"},"variables":{"ENDPOINT":"${{ vars.TEST_ENDPOINT }}"}} +``` + +The reusable workflow exports only the declared entries to module-local test setup, tests, and teardown. Callers omit +`TestData` when no module-local test data is required. + +This YAML is a recommendation derived from the v8 interface and fleet evidence. It is not an approved standard. +Issue [#514](https://github.com/PSModule/Process-PSModule/issues/514) must record agreement on the following structural +decisions before canonical guides, templates, or consumer workflows adopt it: + +| Decision | Candidate | Alternatives still open | +| --- | --- | --- | +| Contract scope | Keep the file identical to the canonical template except for optional `TestData`. | Selected for the candidate; repository-owned jobs use separate workflow files. | +| Trigger ownership | The caller owns manual, schedule, default-branch push, and pull-request triggers. | Move some trigger policy into separate workflows or omit selected event classes. | +| Pull-request activities | Keep all six listed activity types. | Reduce the activity list if a v8 behavior is intentionally unsupported. | +| Concurrency | Use the workflow plus PR-number-or-full-ref key and cancel only pull-request runs. | Selected for the candidate: PR reconciliation must be resumable; non-PR runs serialize by full ref. | +| Permissions | Default deny at workflow level, then grant the caller job `contents: read`, `pages: write`, and `id-token: write`. | Selected for the candidate: use `GITHUB_TOKEN` for repository-local, non-user-facing platform operations and App tokens for user-facing or otherwise unsupported operations. | +| Fork behavior | Keep the caller unconditional; classify fork pull requests as restricted read-only validation in `Plan`. | Selected for the candidate; execution policy belongs to Process-PSModule rather than every consumer. | +| Credentials | Explicitly map the three v8 credentials; optionally map `TestData` when module-local tests need it. | Define a narrower credential profile for repositories that cannot publish. | +| Optional surface | Permit only the documented `TestData` secret mapping. | Selected for the candidate; every other caller-contract field matches the canonical template. | + +The `v8` reference is the controlled moving major tag for this PSModule-owned workflow. On 2026-08-15, `v8`, `v8.0`, +and the immutable `v8.0.0` release tag all resolve to commit `5a11e8e8b018faf97017e0416f136a751c026713`. +`Release-GHRepository` creates and advances major and minor tags by default, while the organization tag ruleset prevents +deletion or non-fast-forward updates of exact `*.*.*` release tags. + +The effective repository rulesets currently protect exact release tags but do not restrict movement of `v8` itself. +Release automation is therefore the operational owner, but actors with sufficient contents access are not yet blocked +from moving the major tag manually. Enforce release-identity-only governance for moving tags before migrating the fleet +to `@v8`; until then, consumers must retain immutable SHA references. + +The reusable workflow remains at `.github/workflows/workflow.yml`. A private cross-repository experiment on 2026-08-15 +confirmed that GitHub rejects a root-level reusable workflow reference with +`references to workflows must be rooted in '.github/workflows'`, even when the provider repository grants the caller +the required Actions access. + +### Owned and external references + +| Automation source | Standard reference | Update model | +| --- | --- | --- | +| PSModule-owned action or reusable workflow | Floating major tag such as `@v8` | Compatible patch and minor releases advance the major tag through controlled release automation. | +| External action or reusable workflow | Full commit SHA with a trailing release-version comment | Dependabot proposes reviewed SHA updates; upstream cannot silently change the referenced code. | + +A major tag never crosses a breaking boundary. `v8` remains on the latest compatible `8.x` release; `v9` begins a new +fleet campaign. Branch names, `latest`, floating minor tags, and unqualified targets are not accepted pins. + +## Candidate common elements + +| Element | Candidate requirement | Reason | +| --- | --- | --- | +| Identity | Keep the candidate file, workflow, and job names shown above. | Stable discovery, status checks, and fleet maintenance. | +| Pull requests | Target `main` and keep all six listed activity types. | CI, prerelease publication, label changes, and closed-PR cleanup depend on them. | +| Default-branch push | Keep `push.branches: [main]`. | `v8` authorizes stable releases from the tested default-branch push. | +| Manual dispatch | Keep `workflow_dispatch`. | Provides the documented default-branch manual release and recovery path. | +| Schedule | Keep a scheduled health run. | Exercises current dependencies even when repository code is unchanged. | +| Concurrency | Use the PR-number-or-ref key and cancel only pull-request runs. | New PR events supersede older declarative reconciliation runs; same-ref push, dispatch, and schedule runs serialize without cancellation. | +| Permissions | Set top-level `permissions: {}` and grant only `contents: read`, `pages: write`, and `id-token: write` to the caller job. | Checkout and Pages remain repository-local built-in capabilities; user-facing interactions and operations outside the built-in token boundary use scoped GitHub App tokens. | +| Event gate | Keep the caller unconditional and authorize capabilities in `Plan`. | The reusable workflow owns execution policy; fork pull requests may validate but cannot obtain App credentials, publish, deploy, clean up, or mutate repository state. | +| Reference | Use the intended internal floating major tag (`v8`) after tag governance is enforced. | Compatible owned releases roll out centrally; breaking releases require a new major and campaign. | +| Credentials | Explicitly map the three required secrets. | Satisfies the `v7+` contract and prevents unrelated secret inheritance. | +| Scope | Require exactly the canonical `Process-PSModule` workflow file. | Repository-owned jobs use separate workflow files. | + +## Allowed caller variation + +The only conforming variation from the canonical template is the optional `TestData` secret mapping shown above. +Callers use it only when module-local tests need caller-defined secrets or variables, and expose only the required +values in the documented `secrets` and `variables` maps. + +Every other field in the Process-PSModule workflow file matches the template exactly. Callers do not add jobs, `with:` +inputs, change schedule timing, add `run-name`, add a caller condition, or broaden permissions. Repository-owned +automation uses separate workflow files. + +## Variations requiring a decision + +The following differ from the candidate. They are inventory classifications, not policy violations, until #514 records +an approved structure: + +- `secrets: inherit`; +- `APIKey` or `APIKEY` mappings from the pre-`v7` contract; +- any Process-PSModule reference other than the intended major tag (`v8`), including a branch, `latest`, minor tag, + exact patch tag, or full commit SHA; +- missing `push` or `unlabeled` triggers; +- a concurrency key other than workflow plus PR number or full ref, or cancellation behavior other than pull-request-only; +- a caller-level fork or event-authorization condition; +- trigger-level path filters that bypass Process-PSModule important-file evaluation; +- any additional job in `.github/workflows/Process-PSModule.yml`; +- any `with:` input, including `Debug`, `ImportantFilePatterns`, `Prerelease`, `SettingsPath`, `Verbose`, `Version`, or + `WorkingDirectory`; +- a schedule other than the canonical `0 0 * * *`; +- `run-name`; +- caller permissions beyond `contents: read`, `pages: write`, and `id-token: write`. + +Use the built-in `GITHUB_TOKEN` for non-user-facing operations confined to the calling repository, including checkout +and the standard GitHub Pages deployment. Create narrowly scoped GitHub App installation tokens for user-facing +interactions such as pull-request comments, labels, statuses, releases, and release cleanup, and whenever the built-in +token cannot provide the required repository or cross-repository access. Tokens remain step-scoped and must not fall +back silently from App authorization to broader built-in-token authority. + +The reusable workflow's Plan job classifies fork-originated `pull_request` events as restricted read-only validation. +It may allow repository-local checkout, build, lint, and test with least-privilege built-in access, but must explicitly +deny App-token creation, Gallery access, publication, Pages deployment, cleanup, and repository or user-facing +mutations. The controlled upstream Plan implementation derives this security envelope from GitHub event metadata before +it interprets repository settings or executes checked-out repository code. Fork-controlled files and settings remain +untrusted build inputs and cannot broaden the planned capabilities. Every downstream job, including jobs using +`always()`, must require a successful valid Plan and the planned capability for its operation before running or +evaluating Settings. Privileged-context events such as +`pull_request_target` remain unsupported unless separately designed to prevent untrusted code from crossing the +credential boundary. + +The contract applies to the entire `.github/workflows/Process-PSModule.yml` file shown above. Repository-owned +automation uses separate workflow files so the canonical caller remains directly comparable across the fleet. + +## Rollout boundary + +This research does not approve or change consumer repositories. If #514 approves the candidate, the campaign would use +the stable slug +`process-v8-major-tag`, one delivery issue, branch, and early draft pull request per repository, and these waves: + +| Wave | Repositories | Change profile | +| --- | ---: | --- | +| Pilot | 1 | Update `Template-PSModule` first and use its final caller as the generated-repository reference. | +| Inherited secrets | 41 | Replace `secrets: inherit` with the three explicit `v8` credential mappings. | +| Old API key only | 14 | Replace `APIKey`/`APIKEY` with the three explicit mappings; excludes the template pilot. | +| Test data | 3 | Preserve each existing `TestData` payload while replacing the old API key contract. | +| Custom input | 1 | Update `Yaml` last, preserve `TestData`, and remove the caller-level `ImportantFilePatterns` override. | + +Before opening leaves: + +1. Record approval of every structural decision above in #514 and update the canonical guides and template. +2. Confirm `v8` and `v8.0.0` resolve to the same tested release commit. +3. Restrict moving major-tag updates to the controlled release identity. Do not start the consumer rollout while another + identity can move `v8`; retain immutable SHA references until this gate is enforced. +4. Have an organization administrator confirm `PSGALLERY_API_KEY`, `SHELLY_CLIENT_ID`, and `SHELLY_PRIVATE_KEY` coverage + in Actions and Dependabot scope. The inventory token can list repository-local secrets but receives `403` for + organization secret visibility, so inherited coverage is currently unresolved. +5. Refresh the inventory with `-TargetReference v8`; the starting target count should be `0/60`. +6. Confirm workflow-only changes are not important release changes. The fleet defaults match only `src/` and + `README.md`; `Yaml` explicitly matches `src/`, `tests/`, and `README.md`, so this campaign should not publish modules. + +After approval, each leaf would apply the canonical caller, retain optional `TestData` only where required, and prove the PR path +before merge. Advance one wave only after the previous wave's push run completes without an unintended release. +Completion would require a fresh inventory showing `60/60` on `v8`, the agreed trigger and concurrency contract, +the agreed credential mapping, and no unresolved review or CI failures. diff --git a/docs/content/reference/process-workflow-lifecycle-design.md b/docs/content/reference/process-workflow-lifecycle-design.md new file mode 100644 index 00000000..476b7de6 --- /dev/null +++ b/docs/content/reference/process-workflow-lifecycle-design.md @@ -0,0 +1,201 @@ +--- +title: Process-PSModule workflow lifecycle design +description: Architecture for Process-PSModule event routing, stamped artifacts, recovery release notes, and concurrency isolation. +--- + +# Process-PSModule workflow lifecycle design + +This design defines the Process-PSModule lifecycle architecture. It implements the +[workflow lifecycle specification](process-workflow-lifecycle-specification.md) through a single policy authority, +immutable release records, and a general release executor. + +## Architecture + +Plan is the sole lifecycle-policy authority. It resolves each event before build and release work, emits enriched +Settings, and gates downstream execution. Build, validation, release, cleanup, and reporting consume Settings without +reinterpreting events, labels, or repository settings. + +One general module release action or reusable workflow consumes Settings and performs stable release, prerelease, +recovery or resume, cleanup-only, or no-op actions. It verifies artifacts when required and reconciles only the +requested state. + +## Event routing + +| Event | Plan classification | Release action | Concurrency | Result | +| --- | --- | --- | --- | --- | +| `workflow_dispatch` on the default branch | Recovery or resume | Stable release or no-op | Full-ref serialization | Rebuild and validate the selected commit; reconstruct unreleased release notes. | +| `schedule` | Published-artifact validation | No-op after validation | Full-ref serialization | Validate the latest published stable artifact and documentation. | +| Fork `pull_request` | Restricted read-only validation | No-op after validation | Pull-request cancellation | Perform repository-local checkout, build, lint, and test only. | +| Pull request `opened`, `reopened`, `synchronize` | Pull-request classification | Prerelease or no-op | Pull-request cancellation | Run validation and execute the planned release action. | +| Pull request `labeled`, `unlabeled` | Pull-request classification refresh | Prerelease, cleanup-only, or no-op | Pull-request cancellation | Resolve the complete current classification and execute its action. | +| Merged pull request `closed` | Post-merge close | No-op | Pull-request cancellation | Leave promotion cleanup to the stable release. | +| Abandoned pull request `closed` | Abandoned-close classification | Cleanup-only | Pull-request cancellation | Reconcile only prereleases owned by the abandoned pull request. | +| Push to the default branch | Stable release | Stable release | Full-ref serialization | Aggregate merged-pull-request intent, publish, and perform promotion cleanup. | + +## Caller boundary + +The [Process-PSModule caller contract](process-workflow-fleet-standard.md) contains exactly one reusable-workflow call +job and the shared top-level triggers, concurrency, permissions, Plan authorization, and credential boundary that govern +it. The file matches the canonical template except for optional `TestData`. Repository-owned automation uses separate +workflow files. + +## Event authorization + +The caller invokes the reusable workflow without a caller-level fork or event condition. The controlled upstream Plan +implementation derives its security and capability envelope from immutable GitHub event metadata before it interprets +repository settings or executes checked-out code. + +For a normal fork `pull_request`, Plan emits a restricted Settings record: + +```text +IsFork=true +AllowAppToken=false +AllowPublication=false +AllowMutation=false +``` + +The restricted route permits only repository-local checkout, build, lint, and test with the least-privilege built-in +token. It provides a green or red validation outcome without contributor secrets. Fork settings and checked-out files +are untrusted validation and build inputs and cannot alter the capability envelope. + +Restricted routes do not create App tokens; access PowerShell Gallery; mutate pull requests, statuses, releases, tags, +or assets; perform cleanup; deploy Pages; or run other privileged or user-facing operations. `pull_request_target` is +rejected before credentials or repository-defined code run. + +Every downstream job first requires successful Plan execution and valid Settings. Jobs using `always()` apply this gate +before their own failure-handling logic. Privileged jobs also require their relevant Settings capability and never parse +missing or invalid Settings. + +## Settings contract + +Settings contains one immutable release record: + +| Field | Purpose | +| --- | --- | +| Event and run type | Identifies the GitHub event and lifecycle classification. | +| Event action | Preserves the pull-request activity or non-pull-request action. | +| Pull-request identity, state, and merge status | Distinguishes active, merged, and abandoned outcomes. | +| Authorization capabilities and evidence | Records `IsFork`, immutable event metadata, and App-token, publication, and mutation capabilities. | +| Labels and repository settings result | Records the inputs resolved by Plan. | +| Version bump and base version | Defines the version transition. | +| Manifest version, prerelease identifier, and full version or tag | Defines the only version and tag permitted in an artifact and release. | +| Target commit | Binds validation, artifact, and release to one source revision. | +| Resolved release action and create or publish flags | Selects stable, prerelease, recovery or resume, cleanup-only, or no-op execution. | +| Cleanup intent and artifact identity | Defines the exact artifacts release execution may reconcile. | +| Release-note source and boundary | Identifies merged pull requests eligible for release notes. | + +## Artifact and version boundary + +Build stamps exactly the manifest version and prerelease identifier in Settings into the module artifact. Before any +package, tag, or release becomes visible, release execution verifies that the artifact equals the Settings record. A +mismatch stops execution; release execution does not recalculate versions or retag artifacts. + +## Repository authorization + +The caller uses the following job boundary: + +```yaml +permissions: {} +jobs: + Process-PSModule: + permissions: + contents: read + pages: write + id-token: write + secrets: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} + GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} +``` + +These explicit secret mappings are required. `secrets: inherit` is prohibited. The only optional secret mapping is +`TestData` for module-local tests: + +```yaml + TestData: ${{ secrets.TestData }} +``` + +When present, `TestData` contains a JSON object with separate `secrets` and `variables` maps. It is omitted when +unused. It is the only permitted variation from the canonical caller template. Callers do not declare `run-name`, +alter the canonical schedule, add jobs or caller conditions, or pass `with:` inputs. + +Built-in `GITHUB_TOKEN` authorizes checkout, repository-local reads, and standard Pages/OIDC deployment within the job +boundary. Step-scoped GitHub App installation tokens authorize pull-request comments and labels, commit statuses and +check-facing reporting, releases, tags, assets, and cleanup. An App-required operation fails before its API request or +mutation when its App token is unavailable; it never falls back to the built-in token. + +Restricted fork Settings override the caller job boundary: no App token is created, no Pages deployment runs, and no +repository mutation or user-facing action runs. + +## Stable aggregation and recovery + +Every stable push and recovery target finds the last successfully published stable version and associated target commit, +then aggregates merged pull requests through the requested target commit. The aggregated release intent determines the +stable version and release-note range. + +Manual recovery accepts a selected default-branch commit and applies the same aggregation and validation path as a +default-branch push. It returns no-op when a stable publication already covers that commit. Release notes use the +ordered, de-duplicated merged-pull-request range rather than manual-dispatch payload data. + +## Scheduled validation and close behavior + +Scheduled validation resolves the latest published stable version as input and sets a validation-only mutation class. +Publication and cleanup execution do not run. + +A merged pull-request close performs no prerelease cleanup. The successful default-branch stable release owns promotion +cleanup. An abandoned pull-request close receives a pull-request-scoped artifact set and performs only prerelease +cleanup. Broad prerelease deletion requires its own exclusive scope and does not share the abandoned-close route. + +## Concurrency and recovery + +The caller uses: + +```yaml +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +``` + +All pull-request events for one pull request share a group and cancel superseded runs. Push, manual-dispatch, and +scheduled events use full `github.ref`, do not cancel a running run, and serialize by ref. GitHub retains one running +and one pending run per group; a later same-group event can replace an earlier pending run. Full `github.ref` prevents +branch and tag name collisions that `github.ref_name` cannot distinguish. + +Cancellation leaves only transient partial state. A subsequent `synchronize`, `labeled`, `unlabeled`, or `closed` +event resumes and reconciles the latest pull-request state: + +| External operation | Reconciliation | +| --- | --- | +| PowerShell Gallery publication | Resolve a deterministic pull-request-scoped prerelease identity, detect the existing version, and continue without duplicate publication. | +| GitHub Release creation | Resume or upsert the release and replace its asset set. | +| Prerelease cleanup | Repeat safely after partial deletion and converge to the latest pull-request state. | +| Production boundary | Do not create a stable or signable production artifact from a pull-request event. | + +## Gallery prerelease disposition + +PowerShell Gallery packages are immutable and cannot be overwritten. Each prerelease uses a deterministic +pull-request-scoped version. When a prerelease becomes obsolete, release execution unlists it through a supported +Gallery API when feasible. When unlisting is infeasible, release execution retains and records the immutable version. +GitHub Release and tag cleanup execute independently from Gallery disposition. + +## Verification + +The lifecycle is verified with event payload fixtures and publication fakes before credentials are used: + +| Behavior | Verification | +| --- | --- | +| Event routing | Fixtures for each supported event and pull-request activity, including merged and abandoned close outcomes. | +| Version boundary | A mismatched artifact fixture proves publication stops. | +| Recovery release notes | Merged-pull-request query fixtures cover empty, single, and multiple pull-request ranges. | +| Scheduled validation | A published-version fixture proves no release mutation is requested. | +| Pull-request convergence | Canceled prerelease publication and cleanup fixtures followed by synchronize, label, unlabel, and close events prove reconciliation. | +| Gallery disposition | Fixtures cover deterministic identity, existing-version detection, supported unlisting, and retained-version recording. | +| Stable aggregation | Push, manual-dispatch, and scheduled bursts replace a pending run and prove all unreleased merged pull requests are aggregated. | +| Caller authorization | Fixtures verify the explicit permissions and credential mappings, App-token failure, and no built-in-token fallback. | +| Fork authorization | Fixtures verify immutable-metadata-first restricted Settings, no privileged operations, and `pull_request_target` rejection. | +| Caller boundary | Fixtures verify exact canonical-template conformance with optional `TestData` as the only variation. | + +## Related + +- [Process-PSModule workflow lifecycle specification](process-workflow-lifecycle-specification.md) +- [Process-PSModule caller contract](process-workflow-fleet-standard.md) diff --git a/docs/content/reference/process-workflow-lifecycle-specification.md b/docs/content/reference/process-workflow-lifecycle-specification.md new file mode 100644 index 00000000..80b17937 --- /dev/null +++ b/docs/content/reference/process-workflow-lifecycle-specification.md @@ -0,0 +1,442 @@ +--- +title: Process-PSModule workflow lifecycle specification +description: Behavior-driven requirements for Process-PSModule event routing, recovery releases, validation, and cleanup. +--- + +# Process-PSModule workflow lifecycle specification + +This specification defines the lifecycle requirements for the Process-PSModule reusable workflow. Requirements follow +[spec-driven development](https://msx.no/docs/Ways-of-Working/Spec-Driven-Development/) and use +[Given / When / Then scenarios](https://msx.no/docs/Ways-of-Working/Spec-Driven-Development/#behavioral-scenarios) as +the acceptance contract. + +## Scope + +The lifecycle covers dispatch recovery, scheduled published-artifact validation, pull-request validation and prerelease +evaluation, closed-pull-request cleanup, and stable publication after a default-branch push. + +The [Process-PSModule caller contract](process-workflow-fleet-standard.md) requires exactly one reusable-workflow call +job and the shared top-level controls that govern it. The workflow file MUST match the canonical template except for +optional `TestData`. Repository-owned automation MUST use separate workflow files. + +## Functional requirements + +### FR1 — Manual dispatch MUST provide a safe recovery release {#fr1} + +A default-branch manual dispatch MUST either publish one stable release after all required validation succeeds or report +that the selected commit is already covered by a stable publication. It MUST NOT create a duplicate stable publication. +Release notes MUST identify the merged pull requests from the last successfully published stable version through the +selected commit. + +#### Behavioral scenarios {#fr1-scenarios} + +```gherkin +Scenario: Recover a missing stable publication + Given the default branch contains a validated commit without a stable publication + When a maintainer dispatches the workflow for that commit + Then the workflow publishes one stable artifact and release for the commit + And the release notes identify merged pull requests since the previous published version + +Scenario: Repeat a completed recovery dispatch + Given a stable publication already covers the selected default-branch commit + When a maintainer dispatches the workflow again + Then the workflow reports that no recovery release is required + And it does not create another artifact, tag, or release +``` + +### FR2 — Scheduled runs MUST validate published artifacts without publishing {#fr2} + +A scheduled run MUST validate the latest published stable artifact and its published documentation against configured +checks. It MUST NOT create, replace, or delete a package, tag, release, or prerelease. + +#### Behavioral scenarios {#fr2-scenarios} + +```gherkin +Scenario: Validate the latest published artifact + Given a stable module version and its documentation are published + When the scheduled workflow runs + Then the workflow validates that published version + And it reports the validated version and result + And it creates no release-related artifact +``` + +### FR3 — Pull-request delivery events MUST run validation only {#fr3} + +An `opened`, `reopened`, or `synchronize` pull-request event targeting the default branch MUST run configured +validation. It MUST NOT create a stable publication. + +#### Behavioral scenarios {#fr3-scenarios} + +```gherkin +Scenario: Validate a synchronized pull request + Given a pull request targets the default branch + When a new commit synchronizes the pull request + Then the workflow reports the configured validation result + And it does not publish a stable version +``` + +### FR4 — Label changes MUST refresh the release classification {#fr4} + +A `labeled` or `unlabeled` pull-request event targeting the default branch MUST resolve the complete classification +from the current label set and repository settings. A prerelease publication MUST occur only when the classification is +prerelease and every required validation succeeds. + +#### Behavioral scenarios {#fr4-scenarios} + +```gherkin +Scenario: Add prerelease eligibility + Given a validated pull request has no prerelease eligibility + When a prerelease label is added + Then Plan resolves the pull request as prerelease eligible + And it publishes at most one eligible prerelease version + +Scenario: Remove prerelease eligibility + Given a pull request has prerelease eligibility + When its prerelease label is removed + Then Plan resolves the pull request as prerelease ineligible + And it does not create a new prerelease version +``` + +### FR5 — Closed pull requests MUST route cleanup by close outcome {#fr5} + +A merged pull-request close MUST NOT perform prerelease cleanup. A successful default-branch stable release MUST own +promotion cleanup. An abandoned pull-request close MUST run cleanup-only behavior for prerelease artifacts associated +with that pull request. Neither close outcome MUST authorize or create a stable publication. + +#### Behavioral scenarios {#fr5-scenarios} + +```gherkin +Scenario: Merge a pull request with prereleases + Given a merged pull request owns prerelease artifacts + When its close event is processed + Then the close event does not clean up prerelease artifacts + And the successful default-branch release owns promotion cleanup + +Scenario: Abandon a pull request with prereleases + Given an unmerged closed pull request owns prerelease artifacts + When its close event is processed + Then the workflow runs cleanup only for that pull request's prerelease artifacts + And no stable artifact, tag, or release is created +``` + +### FR6 — Default-branch pushes MUST authorize stable publication after validation {#fr6} + +A push to the default branch MUST publish a stable version only after all required build, test, quality, and publication +gates succeed. A successful stable release MUST own promotion cleanup. + +#### Behavioral scenarios {#fr6-scenarios} + +```gherkin +Scenario: Publish a merged pull request + Given a merged pull request has an unambiguous release intent + And its merge commit is pushed to the default branch + When all required validation gates succeed + Then the workflow publishes the resulting stable version + And the publication is associated with the pushed commit + And the successful release performs promotion cleanup +``` + +### FR7 — Published artifacts MUST match the planned version {#fr7} + +Every prerelease or stable publication MUST contain the planned manifest version and prerelease identity. A mismatch +MUST fail publication before the release becomes visible. + +#### Behavioral scenarios {#fr7-scenarios} + +```gherkin +Scenario: Reject an incorrectly stamped artifact + Given Plan resolves a release version + And the built artifact reports a different version + When publication is attempted + Then publication fails + And no release is made visible for that artifact +``` + +### FR8 — Pull-request prereleases MUST have deterministic immutable identities {#fr8} + +A pull-request prerelease MUST use a deterministic identity scoped to its pull request. Reprocessing the same +pull-request state MUST resolve the same identity, and different pull requests MUST NOT resolve the same identity. +PowerShell Gallery versions MUST be treated as immutable and MUST NOT be overwritten. + +#### Behavioral scenarios {#fr8-scenarios} + +```gherkin +Scenario: Reprocess the same pull-request state + Given a pull request has resolved a prerelease identity + When the same pull-request state is processed again + Then the workflow resolves the same prerelease identity + And it detects an existing publication instead of attempting an overwrite + +Scenario: Publish prereleases for distinct pull requests + Given two pull requests are eligible for prerelease publication + When both pull requests are processed + Then each pull request resolves a distinct prerelease identity +``` + +### FR9 — Plan MUST resolve lifecycle policy before downstream work {#fr9} + +Plan MUST resolve lifecycle policy before build, test, or release execution. Its enriched Settings MUST contain the +event and run type, event action, pull-request identity, state and merge status, labels, version bump, base version, +manifest version, prerelease identifier, full version or tag, target commit, resolved release action, create and publish +flags, cleanup intent, artifact identity, release-note source and boundary, and authorization capabilities. Downstream +work MUST consume Settings and MUST NOT reinterpret event data, labels, or repository settings. + +#### Behavioral scenarios {#fr9-scenarios} + +```gherkin +Scenario: Execute a planned prerelease action + Given Plan resolves a pull request as an eligible prerelease publication + When downstream work executes + Then it consumes the planned release action and version + And it does not re-evaluate pull-request labels or event data + +Scenario: Execute a planned cleanup-only action + Given Plan resolves an abandoned pull-request close as cleanup only + When release execution runs + Then it reconciles only the planned cleanup state + And it does not create or publish an artifact +``` + +### FR10 — Stable targets MUST aggregate unreleased merged pull requests {#fr10} + +For every stable push or recovery target, Plan MUST aggregate merged pull requests and their release intent from the +last successfully published version through the target commit. The resulting stable action MUST converge when GitHub +replaces an intermediate pending run. + +#### Behavioral scenarios {#fr10-scenarios} + +```gherkin +Scenario: Publish after an intermediate pending push is replaced + Given merged pull requests exist after the last successfully published version + And an intermediate default-branch push is replaced while pending + When a later default-branch push is planned + Then Plan aggregates every merged pull request through the later target commit + And the stable release uses the aggregated release intent + +Scenario: Recover a range of unreleased merged pull requests + Given merged pull requests exist after the last successfully published version + When a maintainer dispatches recovery for a later default-branch commit + Then Plan aggregates every merged pull request through that target commit + And the release notes use that aggregated range +``` + +### FR11 — Repository operations MUST use scoped authorization {#fr11} + +The caller MUST declare top-level `permissions: {}`. Its Process-PSModule job MUST grant only `contents: read`, +`pages: write`, and `id-token: write`. It MUST explicitly map `PSGALLERY_API_KEY`, `GitHubAppClientId`, and +`GitHubAppPrivateKey`; `secrets: inherit` MUST NOT be used. It MAY map `TestData` only for module-local tests. +When present, `TestData` MUST contain a JSON object with separate `secrets` and `variables` maps; callers MUST omit it +when unused. `TestData` MUST be the only variation from the canonical caller template. A caller MUST NOT declare +`run-name`, alter the canonical schedule, add a caller condition, or pass any `with:` input. + +Built-in `GITHUB_TOKEN` MAY authorize repository-local, non-user-facing work when those permissions are sufficient, +including checkout, reads, and standard Pages/OIDC deployment. GitHub App installation tokens MUST authorize all +user-facing interactions and operations beyond the built-in token boundary, including pull-request comments and labels, +commit statuses and check-facing reporting, releases, tags, assets, and cleanup. Tokens MUST remain scoped to the steps +that require them. + +#### Behavioral scenarios {#fr11-scenarios} + +```gherkin +Scenario: Run with the caller's minimum permissions + Given the caller declares top-level permissions as an empty object + And its Process-PSModule job grants only contents read, Pages write, and ID-token write + When the reusable workflow performs checkout or standard Pages deployment + Then it may use the built-in workflow token within that granted boundary + +Scenario: Provide the required caller credentials explicitly + Given a conforming caller invokes the reusable workflow + When it maps credentials to the Process-PSModule job + Then it maps PSGALLERY_API_KEY, GitHubAppClientId, and GitHubAppPrivateKey explicitly + And it does not use secrets inherit + +Scenario: Provide optional module-local test data + Given module-local tests require caller-provided data + When the caller maps TestData + Then its secret value is a JSON object with separate secrets and variables maps + And the caller omits TestData when tests do not require it + +Scenario: Match the canonical caller template + Given a conforming caller invokes the reusable workflow + When its Process-PSModule caller contract is compared with the canonical template + Then every field matches the template + And TestData is the only permitted optional mapping + +Scenario: Perform a user-facing repository operation + Given the reusable workflow must create a pull-request comment or release + When the operation requires authority beyond the built-in token boundary + Then it creates a narrowly scoped GitHub App installation token + And it uses the App token only for the steps that require that authority +``` + +### FR12 — Plan MUST authorize events before downstream execution {#fr12} + +The caller MUST invoke the reusable workflow without a caller-level fork or event condition. For a normal fork +`pull_request`, the controlled upstream Plan implementation MUST derive a restricted capability envelope from immutable +GitHub event metadata, including fork, base, and head identities, before interpreting repository settings or executing +checked-out code. The Settings record MUST set `IsFork=true`, `AllowAppToken=false`, `AllowPublication=false`, and +`AllowMutation=false`. + +The restricted mode MAY perform repository-local checkout, build, lint, and test with the least-privilege built-in +token. It MUST NOT create an App token; access PowerShell Gallery; create pull-request comments, labels, or status +mutations; create releases, tags, or assets; perform cleanup; deploy Pages; or run another privileged or user-facing +operation. Fork settings and files MAY be consumed only as untrusted validation and build inputs and MUST NOT broaden +the capability envelope. The restricted mode MUST provide a green or red validation outcome without contributor secrets. + +`pull_request_target` MUST be rejected before credentials or repository-defined code run. Every downstream job, +including a job with `always()`, MUST require successful Plan execution and valid Settings. Every privileged downstream +job MUST also require its relevant planned capability. No downstream job MAY evaluate missing or invalid Settings or +bypass the Plan gate. + +#### Behavioral scenarios {#fr12-scenarios} + +```gherkin +Scenario: Validate a normal fork pull request in restricted mode + Given a pull request originates from a fork through the pull_request event + When Plan evaluates immutable fork, base, and head metadata + Then Plan emits valid restricted Settings + And downstream work may perform only repository-local checkout, build, lint, and test + And the contributor receives a green or red validation outcome without configured secrets + +Scenario: Prevent untrusted inputs from expanding fork capabilities + Given a pull request originates from a fork through the pull_request event + And its repository settings attempt to enable publication + When Plan reads the settings or checked-out files + Then App-token, publication, mutation, deployment, and cleanup capabilities remain false + +Scenario: Reject a pull_request_target event + Given a pull_request_target event is received + When Plan evaluates the event + Then Plan rejects the event before credentials or repository-defined code run + And no downstream job receives authorized Settings + +Scenario: Gate a privileged job for a restricted fork run + Given Plan emits valid restricted Settings for a fork pull request + And the planned capability for publication is false + When a publication job is evaluated + Then the job does not run + And it does not create an App token or parse an absent publication configuration +``` + +### FR13 — The caller workflow file MUST match the canonical template {#fr13} + +A conforming `.github/workflows/Process-PSModule.yml` MUST match the canonical template exactly except for optional +`TestData`. Repository-owned automation MUST use separate workflow files. + +#### Behavioral scenarios {#fr13-scenarios} + +```gherkin +Scenario: Keep repository-owned automation separate + Given a repository needs a documentation job + When the workflow is evaluated for caller conformance + Then the documentation job exists in a separate workflow file + And Process-PSModule.yml still matches the canonical template + +Scenario: Reject any other caller variation + Given Process-PSModule.yml differs from the canonical template + When the difference is not the optional TestData mapping + Then the caller is nonconforming +``` + +## Non-functional requirements + +### NFR1 — Lifecycle mutations MUST be idempotent {#nfr1} + +Retrying the same event for the same commit and resolved version MUST produce no more than one package, tag, and +release for that version. + +```gherkin +Scenario: Retry a publication after an interrupted run + Given a publication for a resolved version was interrupted + When the workflow retries the same event + Then it completes the missing work or reports the completed work + And it does not duplicate the package, tag, or release +``` + +### NFR2 — Pull-request cancellation MUST preserve non-pull-request serialization {#nfr2} + +The caller MUST use: + +```yaml +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +``` + +All pull-request events for one pull request share a cancellation scope. Push, manual dispatch, and scheduled runs +share a full-ref serialization scope and MUST NOT cancel an in-progress run. GitHub permits one running and one pending +run per group; a later non-pull-request run MAY replace an earlier pending run. Stable planning MUST therefore converge +from the last successfully published version. The group MUST use full `github.ref`; `github.ref_name` does not +distinguish colliding branch and tag names. + +```gherkin +Scenario: Serialize non-pull-request runs + Given a default-branch release is in progress + And an earlier default-branch run is pending + When a manual dispatch or scheduled validation starts for the same full ref + Then the later run does not cancel the running release + And it may replace the earlier pending run + And the next stable plan aggregates the unreleased merged pull requests +``` + +### NFR3 — Each lifecycle outcome MUST be auditable {#nfr3} + +Every run MUST report its event category, resolved or validated version, release decision, and terminal outcome. + +### NFR4 — Pull-request mutation paths MUST resume and converge {#nfr4} + +Every pull-request path, including prerelease publication and cleanup, MUST be idempotent and resumable after +cancellation. The next `synchronize`, `labeled`, `unlabeled`, or `closed` event MUST reconcile release-related state +to the latest pull-request state. Cancellation MAY leave transient partial state but MUST NOT leave a permanent +duplicate or obsolete artifact without its required disposition. + +### NFR5 — Pull-request events MUST NOT produce production artifacts {#nfr5} + +A pull-request event MUST NOT create a stable or signable production artifact. Pull-request events MAY create only +eligible prerelease artifacts and associated metadata. + +### NFR6 — Immutable Gallery prereleases MUST have a durable disposition {#nfr6} + +An obsolete PowerShell Gallery prerelease MUST be unlisted through a supported Gallery API when feasible. When unlisting +is infeasible, the workflow MUST retain and record the immutable version. GitHub Release and tag cleanup MUST execute +independently from Gallery disposition. + +```gherkin +Scenario: Dispose of an obsolete Gallery prerelease + Given a pull-request prerelease is obsolete + And a supported Gallery API can unlist that version + When the prerelease is reconciled + Then the workflow unlists the immutable Gallery package + And it performs GitHub Release and tag cleanup independently + +Scenario: Retain an immutable Gallery prerelease + Given a pull-request prerelease is obsolete + And unlisting that Gallery version is infeasible + When the prerelease is reconciled + Then the workflow records the retained immutable Gallery version + And it performs GitHub Release and tag cleanup independently +``` + +### NFR7 — App-required operations MUST fail closed {#nfr7} + +When an operation requires GitHub App authorization and the required App token is unavailable, the operation MUST fail +before an unauthorized API request, status update, comment, release, tag or asset mutation, or cleanup. It MUST NOT +fall back to built-in `GITHUB_TOKEN` authority. + +## Cross-cutting acceptance criteria + +```gherkin +Scenario: Lifecycle runs preserve release ownership after cancellation + Given a scheduled validation and an abandoned pull-request cleanup overlap a main-push release + And the cleanup supersedes a canceled prerelease publication + When all three runs complete + Then the scheduled run reports validation without a release mutation + And the cleanup reconciles only the abandoned pull request's prereleases + And none of the runs cancel the main-push release + And the main-push run is the only run that publishes the stable release and performs promotion cleanup +``` + +## Related + +- [Process-PSModule workflow lifecycle design](process-workflow-lifecycle-design.md) +- [Process-PSModule caller contract](process-workflow-fleet-standard.md) diff --git a/docs/zensical.toml b/docs/zensical.toml index cdd222bf..aacf6057 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -47,6 +47,8 @@ nav = [ {"Scenario matrix" = "reference/scenario-matrix.md"}, {"Framework test IDs" = "reference/framework-test-ids.md"}, {"Dependencies" = "reference/dependencies.md"}, + {"Workflow lifecycle specification" = "reference/process-workflow-lifecycle-specification.md"}, + {"Workflow lifecycle design" = "reference/process-workflow-lifecycle-design.md"}, ]}, {"Specification" = [ "specification/index.md",