diff --git a/.github/workflows/self-hosted-machine-certification.yml b/.github/workflows/self-hosted-machine-certification.yml index 3845704..d9e6817 100644 --- a/.github/workflows/self-hosted-machine-certification.yml +++ b/.github/workflows/self-hosted-machine-certification.yml @@ -295,23 +295,32 @@ jobs: trace_jq matrix_routing_diagnostics jq -c ' map( - (.runner_labels_json | fromjson | map(tostring | gsub("^\\s+|\\s+$";""))) as $labels + (.runner_labels_json | fromjson | map(tostring | gsub("^\\s+|\\s+$";"") | ascii_downcase)) as $labels | (.setup_name // "") as $setup_name + | ($labels | map(select(startswith("cert-setup-")))) as $setup_labels + | ($labels | map(select(startswith("cert-actor-")))) as $actor_labels | { setup_name: $setup_name, expected_labview_year: .expected_labview_year, contract_labview_year: .contract_labview_year, required_setup_label: ("cert-setup-" + $setup_name), requires_actor_label: ((.expected_labview_year == "2025") or (.expected_labview_year == "2026")), + labels: $labels, + setup_labels: $setup_labels, + setup_label_count: ($setup_labels | length), has_setup_label: ($labels | index("cert-setup-" + $setup_name) != null), - actor_labels: ($labels | map(select(startswith("cert-actor-")))), - has_actor_label: (($labels | map(select(startswith("cert-actor-"))) | length) > 0), - labels: $labels + has_single_setup_label: (($setup_labels | length) == 1), + has_only_required_setup_label: (($setup_labels | length) == 1 and $setup_labels[0] == ("cert-setup-" + $setup_name)), + actor_labels: $actor_labels, + actor_label_count: ($actor_labels | length), + has_actor_label: (($actor_labels | length) > 0), + has_single_actor_label: (($actor_labels | length) == 1), + has_actor_label_format: (($actor_labels | all(test("^cert-actor-[a-z0-9-]+-[a-z0-9-]+$")))) } )' matrix.json > matrix-routing-diagnostics.json trace_jq routing_failure_count - ROUTING_FAILURE_COUNT="$(jq '[ .[] | select((.has_setup_label | not) or (.requires_actor_label and (.has_actor_label | not))) ] | length' matrix-routing-diagnostics.json)" + ROUTING_FAILURE_COUNT="$(jq '[ .[] | select((.has_setup_label | not) or (.has_single_setup_label | not) or (.has_only_required_setup_label | not) or (.requires_actor_label and ((.has_actor_label | not) or (.has_single_actor_label | not) or (.has_actor_label_format | not)))) ] | length' matrix-routing-diagnostics.json)" { echo "### Routing matrix contract" trace_jq matrix_count @@ -323,12 +332,12 @@ jobs: if jq -e '.[] | select(.requires_actor_label == true)' matrix-routing-diagnostics.json > /dev/null; then echo "#### 2025/2026 actor-label diagnostics" >> "$GITHUB_STEP_SUMMARY" trace_jq matrix_actor_diagnostics - jq -r '.[] | select(.requires_actor_label == true) | "- \(.setup_name): setup_label=\(.has_setup_label) actor_label=\(.has_actor_label) actor_tokens=\(.actor_labels | join(","))"' matrix-routing-diagnostics.json >> "$GITHUB_STEP_SUMMARY" + jq -r '.[] | select(.requires_actor_label == true) | "- \(.setup_name): setup_labels=\(.setup_labels | join(",")) setup_count=\(.setup_label_count) actor_labels=\(.actor_labels | join(",")) actor_count=\(.actor_label_count) actor_format=\(.has_actor_label_format)"' matrix-routing-diagnostics.json >> "$GITHUB_STEP_SUMMARY" fi if [[ "$ROUTING_FAILURE_COUNT" != "0" ]]; then - echo "::error::routing_matrix_contract_failed: setup and actor label contract violations detected." + echo "::error::routing_matrix_contract_failed: setup/actor label cardinality or format contract violations detected." trace_jq matrix_routing_failures_dump - jq '.[] | select((.has_setup_label | not) or (.requires_actor_label and (.has_actor_label | not)))' matrix-routing-diagnostics.json + jq '.[] | select((.has_setup_label | not) or (.has_single_setup_label | not) or (.has_only_required_setup_label | not) or (.requires_actor_label and ((.has_actor_label | not) or (.has_single_actor_label | not) or (.has_actor_label_format | not))))' matrix-routing-diagnostics.json exit 1 fi trace_jq matrix_emit_setups_json @@ -530,18 +539,63 @@ jobs: } $requiredSetupLabel = ("cert-setup-{0}" -f $setupName).ToLowerInvariant() + $setupLabels = @($labels | Where-Object { [string]$_ -like 'cert-setup-*' } | Select-Object -Unique) + $setupLabelCount = @($setupLabels).Count $hasSetupLabel = @($labels | Where-Object { [string]$_ -eq $requiredSetupLabel }).Count -gt 0 + $hasSingleSetupLabel = $setupLabelCount -eq 1 + $hasOnlyRequiredSetupLabel = $hasSingleSetupLabel -and [string]::Equals([string]$setupLabels[0], $requiredSetupLabel, [System.StringComparison]::Ordinal) + $actorLabels = @($labels | Where-Object { [string]$_ -like 'cert-actor-*' } | Select-Object -Unique) + $actorLabelCount = @($actorLabels).Count $requiresActorLabel = $is2025Or2026 - $hasActorLabel = @($actorLabels).Count -gt 0 + $hasActorLabel = $actorLabelCount -gt 0 + $hasSingleActorLabel = $actorLabelCount -eq 1 + $hasActorLabelFormat = @($actorLabels | Where-Object { [string]$_ -notmatch '^cert-actor-[a-z0-9-]+-[a-z0-9-]+$' }).Count -eq 0 + $expectedActorLabel = if ($requiresActorLabel) { + $machineToken = ([string]$currentMachine).Trim().ToLowerInvariant() + $machineToken = [Regex]::Replace($machineToken, '[^a-z0-9\-]', '-') + $machineToken = [Regex]::Replace($machineToken, '-{2,}', '-') + $machineToken = $machineToken.Trim('-') + if ([string]::IsNullOrWhiteSpace($machineToken)) { $machineToken = 'unknown' } + + $setupToken = ([string]$setupName).Trim().ToLowerInvariant() + $setupToken = [Regex]::Replace($setupToken, '[^a-z0-9\-]', '-') + $setupToken = [Regex]::Replace($setupToken, '-{2,}', '-') + $setupToken = $setupToken.Trim('-') + if ([string]::IsNullOrWhiteSpace($setupToken)) { $setupToken = 'unknown' } + + $label = ("cert-actor-{0}-{1}" -f $machineToken, $setupToken) + if ($label.Length -gt 63) { + $label = $label.Substring(0, 63).TrimEnd('-') + } + $label + } else { + '' + } + $hasExpectedActorLabel = if ($requiresActorLabel) { $actorLabels -contains $expectedActorLabel } else { $true } $failures = @() if (-not $hasSetupLabel) { $failures += ("missing setup route label '{0}'." -f $requiredSetupLabel) } + if (-not $hasSingleSetupLabel) { + $failures += ("setup route label cardinality invalid; expected exactly 1 cert-setup-* label, found {0}." -f $setupLabelCount) + } + if (-not $hasOnlyRequiredSetupLabel) { + $failures += ("setup route label mismatch; expected '{0}', resolved '{1}'." -f $requiredSetupLabel, ($setupLabels -join ',')) + } if ($requiresActorLabel -and -not $hasActorLabel) { $failures += "missing required actor route label token 'cert-actor-*'." } + if ($requiresActorLabel -and -not $hasSingleActorLabel) { + $failures += ("actor route label cardinality invalid; expected exactly 1 cert-actor-* label, found {0}." -f $actorLabelCount) + } + if ($requiresActorLabel -and -not $hasActorLabelFormat) { + $failures += "actor route label format invalid; expected pattern '^cert-actor-[a-z0-9-]+-[a-z0-9-]+$'." + } + if ($requiresActorLabel -and $hasActorLabel -and -not $hasExpectedActorLabel) { + $failures += ("actor route label mismatch; expected '{0}', resolved '{1}'." -f $expectedActorLabel, ($actorLabels -join ',')) + } $reportPath = Join-Path '${{ steps.runner-metadata.outputs.metadata_root }}' 'routing-contract-report.json' [ordered]@{ @@ -555,9 +609,18 @@ jobs: labels_parse_mode = $labelsParseMode labels_raw_count = [int]$labelsRawCount labels_flattened_count = [int]$labelsFlattenedCount + setup_labels = @($setupLabels) + setup_label_count = $setupLabelCount has_setup_label = $hasSetupLabel + has_single_setup_label = $hasSingleSetupLabel + has_only_required_setup_label = $hasOnlyRequiredSetupLabel + expected_actor_label = $expectedActorLabel + has_expected_actor_label = $hasExpectedActorLabel has_actor_label = $hasActorLabel + has_single_actor_label = $hasSingleActorLabel + has_actor_label_format = $hasActorLabelFormat actor_labels = @($actorLabels) + actor_label_count = $actorLabelCount labels = @($labels) failures = @($failures) pass = (@($failures).Count -eq 0) @@ -576,10 +639,19 @@ jobs: "- labels raw count: $labelsRawCount", "- labels flattened count: $labelsFlattenedCount", "- required setup label: $requiredSetupLabel", + "- setup labels: $(if (@($setupLabels).Count -gt 0) { $setupLabels -join ', ' } else { '(none)' })", + "- setup label count: $setupLabelCount", "- setup label present: $hasSetupLabel", + "- setup label cardinality valid: $hasSingleSetupLabel", + "- setup label exact match: $hasOnlyRequiredSetupLabel", "- actor labels: $(if (@($actorLabels).Count -gt 0) { $actorLabels -join ', ' } else { '(none)' })", "- actor label required: $requiresActorLabel", + "- expected actor label: $(if ($requiresActorLabel) { $expectedActorLabel } else { '(n/a)' })", + "- actor label count: $actorLabelCount", "- actor label present: $hasActorLabel", + "- actor label cardinality valid: $hasSingleActorLabel", + "- actor label format valid: $hasActorLabelFormat", + "- actor label exact match: $hasExpectedActorLabel", "- pass: $(@($failures).Count -eq 0)" ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 } @@ -2039,3 +2111,6 @@ jobs: if-no-files-found: error + + + diff --git a/scripts/Start-SelfHostedMachineCertification.ps1 b/scripts/Start-SelfHostedMachineCertification.ps1 index 647c37d..28afce6 100644 --- a/scripts/Start-SelfHostedMachineCertification.ps1 +++ b/scripts/Start-SelfHostedMachineCertification.ps1 @@ -136,7 +136,7 @@ function Assert-DispatchRoutingContract { $labels = @( Split-LabelCsv -Csv $RunnerLabelsCsv | - ForEach-Object { ([string]$_).Trim() } | + ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique ) @@ -145,12 +145,13 @@ function Assert-DispatchRoutingContract { } $requiredSetupLabel = Get-SetupRouteLabel -Setup $Setup - $setupLabels = @($labels | Where-Object { [string]$_ -like 'cert-setup-*' }) - if ($labels -notcontains $requiredSetupLabel) { - throw ("dispatch_routing_setup_label_missing: setup '{0}' requires setup label '{1}' in runner labels '{2}'." -f [string]$Setup.name, $requiredSetupLabel, ($labels -join ',')) + $setupLabels = @($labels | Where-Object { [string]$_ -like 'cert-setup-*' } | Select-Object -Unique) + $setupLabelCount = @($setupLabels).Count + if ($setupLabelCount -ne 1) { + throw ("dispatch_routing_setup_label_count_invalid: setup '{0}' requires exactly one setup route label '{1}', found count={2} labels='{3}'." -f [string]$Setup.name, $requiredSetupLabel, $setupLabelCount, ($setupLabels -join ',')) } - if (@($setupLabels | Where-Object { [string]$_ -ne $requiredSetupLabel }).Count -gt 0) { - throw ("dispatch_routing_setup_label_ambiguous: setup '{0}' resolved multiple setup route labels '{1}'." -f [string]$Setup.name, ($setupLabels -join ',')) + if (-not [string]::Equals([string]$setupLabels[0], $requiredSetupLabel, [System.StringComparison]::Ordinal)) { + throw ("dispatch_routing_setup_label_mismatch: setup '{0}' requires setup label '{1}' but resolved '{2}'." -f [string]$Setup.name, $requiredSetupLabel, [string]$setupLabels[0]) } $expectedLabviewYear = Get-SetupExpectedLabviewYear -Setup $Setup @@ -158,28 +159,37 @@ function Assert-DispatchRoutingContract { $owner = Get-RepositoryOwner -Repository $Repository $requiresActorLabel = $requiresActorLabelByYear -and [string]::Equals($owner, 'LabVIEW-Community-CI-CD', [System.StringComparison]::OrdinalIgnoreCase) - $actorLabels = @($labels | Where-Object { [string]$_ -like 'cert-actor-*' }) - $actorLabelPresent = @($actorLabels).Count -gt 0 + $expectedActorLabel = ([string]$ActorRunnerLabel).Trim().ToLowerInvariant() + $actorLabels = @($labels | Where-Object { [string]$_ -like 'cert-actor-*' } | Select-Object -Unique) + $actorLabelCount = @($actorLabels).Count + $actorLabelPresent = $actorLabelCount -gt 0 if ($requiresActorLabel -and -not $actorLabelPresent) { throw ("dispatch_routing_actor_label_missing: setup '{0}' (LabVIEW {1}) requires actor label for upstream dispatch; labels='{2}'." -f [string]$Setup.name, $expectedLabviewYear, ($labels -join ',')) } - if ($requiresActorLabel -and $labels -notcontains $ActorRunnerLabel) { - throw ("dispatch_routing_actor_label_mismatch: setup '{0}' expected actor label '{1}' but resolved labels were '{2}'." -f [string]$Setup.name, $ActorRunnerLabel, ($labels -join ',')) + if ($requiresActorLabel -and $actorLabelCount -ne 1) { + throw ("dispatch_routing_actor_label_count_invalid: setup '{0}' expected exactly one actor label '{1}', found count={2} labels='{3}'." -f [string]$Setup.name, $expectedActorLabel, $actorLabelCount, ($actorLabels -join ',')) + } + if ($requiresActorLabel -and $actorLabelCount -gt 0 -and $actorLabels[0] -notmatch '^cert-actor-[a-z0-9-]+-[a-z0-9-]+$') { + throw ("dispatch_routing_actor_label_format_invalid: setup '{0}' resolved actor label '{1}' with invalid format." -f [string]$Setup.name, [string]$actorLabels[0]) + } + if ($requiresActorLabel -and $actorLabelCount -eq 1 -and -not [string]::Equals([string]$actorLabels[0], $expectedActorLabel, [System.StringComparison]::Ordinal)) { + throw ("dispatch_routing_actor_label_mismatch: setup '{0}' expected actor label '{1}' but resolved '{2}'." -f [string]$Setup.name, $expectedActorLabel, [string]$actorLabels[0]) } return [pscustomobject]@{ pass = $true expected_labview_year = $expectedLabviewYear required_setup_label = $requiredSetupLabel + setup_label_count = $setupLabelCount setup_labels_csv = ($setupLabels -join ',') requires_actor_label = $requiresActorLabel - required_actor_label = $ActorRunnerLabel + required_actor_label = $expectedActorLabel + actor_label_count = $actorLabelCount actor_label_present = $actorLabelPresent actor_labels_csv = ($actorLabels -join ',') resolved_labels_csv = ($labels -join ',') } } - function Get-ActorMachineName { param([string]$PreferredName) diff --git a/tests/SelfHostedMachineCertificationWorkflowContract.Tests.ps1 b/tests/SelfHostedMachineCertificationWorkflowContract.Tests.ps1 index 37e7623..591cfff 100644 --- a/tests/SelfHostedMachineCertificationWorkflowContract.Tests.ps1 +++ b/tests/SelfHostedMachineCertificationWorkflowContract.Tests.ps1 @@ -70,13 +70,26 @@ Describe 'Self-hosted machine certification workflow contract' { $script:workflowContent | Should -Match 'routing_matrix_contract_failed' $script:workflowContent | Should -Match 'required_setup_label' $script:workflowContent | Should -Match 'requires_actor_label' + $script:workflowContent | Should -Match 'has_single_setup_label' + $script:workflowContent | Should -Match 'has_only_required_setup_label' + $script:workflowContent | Should -Match 'actor_label_count' + $script:workflowContent | Should -Match 'has_single_actor_label' + $script:workflowContent | Should -Match 'has_actor_label_format' $script:workflowContent | Should -Match '\.expected_labview_year == "2025"\) or \(\.expected_labview_year == "2026"' } + It 'fails fast per lane when setup or actor routing labels are missing' { $script:workflowContent | Should -Match 'id:\s*routing-contract' $script:workflowContent | Should -Match 'Assert setup/actor routing labels' $script:workflowContent | Should -Match 'routing_label_contract_failed' $script:workflowContent | Should -Match 'routing-contract-report\.json' + $script:workflowContent | Should -Match 'labels_parse_mode' + $script:workflowContent | Should -Match 'labels_raw_count' + $script:workflowContent | Should -Match 'labels_flattened_count' + $script:workflowContent | Should -Match 'has_only_required_setup_label' + $script:workflowContent | Should -Match 'has_single_actor_label' + $script:workflowContent | Should -Match 'has_actor_label_format' + $script:workflowContent | Should -Match 'has_expected_actor_label' $script:workflowContent | Should -Match '2025/2026 routing diagnostics' $script:workflowContent | Should -Match '\$parsedLabels\s*=\s*\$labelsJson\s*\|\s*ConvertFrom-Json' $script:workflowContent | Should -Match 'labels_parse_mode' diff --git a/tests/StartSelfHostedMachineCertificationContract.Tests.ps1 b/tests/StartSelfHostedMachineCertificationContract.Tests.ps1 index 3ebff83..bc6a1ce 100644 --- a/tests/StartSelfHostedMachineCertificationContract.Tests.ps1 +++ b/tests/StartSelfHostedMachineCertificationContract.Tests.ps1 @@ -42,11 +42,15 @@ Describe 'Start self-hosted machine certification dispatch contract' { $script:startScriptContent | Should -Match 'function Get-SetupExpectedLabviewYear' $script:startScriptContent | Should -Match 'function Test-SetupRequiresActorLabel' $script:startScriptContent | Should -Match 'function Assert-DispatchRoutingContract' - $script:startScriptContent | Should -Match 'dispatch_routing_setup_label_missing' + $script:startScriptContent | Should -Match 'dispatch_routing_setup_label_count_invalid' + $script:startScriptContent | Should -Match 'dispatch_routing_setup_label_mismatch' $script:startScriptContent | Should -Match 'dispatch_routing_actor_label_missing' + $script:startScriptContent | Should -Match 'dispatch_routing_actor_label_count_invalid' + $script:startScriptContent | Should -Match 'dispatch_routing_actor_label_format_invalid' $script:startScriptContent | Should -Match 'dispatch_routing_actor_label_mismatch' $script:startScriptContent | Should -Match '\$routingContract = Assert-DispatchRoutingContract' $script:startScriptContent | Should -Match 'routing_contract_requires_actor_label = \[bool\]\$routingContract\.requires_actor_label' + $script:startScriptContent | Should -Match 'routing_contract_required_actor_label = \[string\]\$routingContract\.required_actor_label' } }