From c60abdf365e7b2ac37cbf83e220a9c56fff82ceb Mon Sep 17 00:00:00 2001 From: svelderrainruiz Date: Fri, 27 Feb 2026 12:27:09 -0800 Subject: [PATCH] Add Tier-0 CLI dependency gate to release control-plane --- .github/workflows/ci.yml | 1 + AGENTS.md | 2 + README.md | 3 + scripts/Invoke-CliDependencyGate.ps1 | 366 ++++++++++++++++++ scripts/Invoke-ReleaseControlPlane.ps1 | 232 ++++++++++- scripts/Test-PolicyContracts.ps1 | 8 + scripts/Test-ReleaseClientContracts.ps1 | 10 + .../Test-ReleaseControlPlanePolicyDrift.ps1 | 36 ++ ...Write-ReleaseControlPlaneDecisionTrail.ps1 | 13 + ...easeClientDependencyGateContract.Tests.ps1 | 45 +++ tests/ReleaseClientPolicyContract.Tests.ps1 | 15 + ...ontrolPlaneDecisionTrailContract.Tests.ps1 | 3 + ...easeControlPlaneWorkflowContract.Tests.ps1 | 7 + tests/WorkspaceSurfaceContract.Tests.ps1 | 2 + .../scripts/Test-PolicyContracts.ps1 | 52 +++ .../workspace-governance.json | 26 +- workspace-governance.json | 26 +- 17 files changed, 844 insertions(+), 3 deletions(-) create mode 100644 scripts/Invoke-CliDependencyGate.ps1 create mode 100644 tests/ReleaseClientDependencyGateContract.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a05e6b6..21ba63e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: './tests/ReleaseManifestContract.Tests.ps1', './tests/ReleaseClientRuntimeContract.Tests.ps1', './tests/ReleaseClientPolicyContract.Tests.ps1', + './tests/ReleaseClientDependencyGateContract.Tests.ps1', './tests/WorkspaceInstallRuntimeContract.Tests.ps1', './tests/Build-WorkspaceBootstrapInstaller.Tests.ps1', './tests/Build-RunnerCliBundleFromManifest.Tests.ps1', diff --git a/AGENTS.md b/AGENTS.md index f3877c9..1c779d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,6 +217,8 @@ Build and gate lanes must run in isolated workspaces on every run (`D:\dev` pref - `ops_control_plane_policy.state_machine` is required and must emit runtime transition evidence in `release-control-plane-report.json`. - `ops_control_plane_policy.rollback_orchestration` is required and controls deterministic rollback self-healing trigger behavior. - `ops_control_plane_policy.decision_trail` is required and controls deterministic decision-trail evidence emission (`release-control-plane-decision-trail.json`). +- `ops_control_plane_policy.cli_dependency_gate` is required and controls Tier-0 cdev-cli dependency hard-block (`PromotePrerelease|PromoteStable|FullCycle`) vs warn-only (`Validate|CanaryCycle`) behavior. +- `scripts/Invoke-CliDependencyGate.ps1` is the canonical dependency-gate runtime and must emit `sync_guard_evidence` + `runtime_evidence` fields in control-plane reports. - Control-plane mode contract: - `Validate` - `CanaryCycle` diff --git a/README.md b/README.md index 8d95e06..87f8915 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,9 @@ Control-plane behavior: 13. Loads GA policy contract `installer_contract.release_client.ops_control_plane_policy.schema_version=2.0` and emits state-machine execution evidence (`state_machine.transitions_executed`) in every report. 14. Executes deterministic rollback orchestration (`Invoke-RollbackDrillSelfHealing.ps1`) when configured trigger reason codes are hit. 15. Emits deterministic decision-trail evidence artifact `release-control-plane-decision-trail.json` (report hash + state-machine + rollback evidence fingerprint). +16. Evaluates Tier-0 cdev-cli dependency gate (`scripts/Invoke-CliDependencyGate.ps1`) and emits `cli_dependency_gate` evidence with mode-based enforcement: + - hard-block: `PromotePrerelease`, `PromoteStable`, `FullCycle` + - warn-only: `Validate`, `CanaryCycle` Top-level release-control-plane deterministic failure reason codes include: - `ops_health_gate_failed` diff --git a/scripts/Invoke-CliDependencyGate.ps1 b/scripts/Invoke-CliDependencyGate.ps1 new file mode 100644 index 0000000..ff14d24 --- /dev/null +++ b/scripts/Invoke-CliDependencyGate.ps1 @@ -0,0 +1,366 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter()] + [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] + [string]$SyncGuardRepository = 'LabVIEW-Community-CI-CD/labview-cdev-cli', + + [Parameter()] + [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] + [string]$ForkRepository = 'svelderrainruiz/labview-cdev-cli', + + [Parameter()] + [ValidatePattern('^[A-Za-z0-9._/-]+$')] + [string]$SyncGuardBranch = 'main', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$SyncGuardWorkflow = 'fork-upstream-sync-guard', + + [Parameter()] + [ValidateRange(1, 168)] + [int]$SyncGuardMaxAgeHours = 12, + + [Parameter()] + [string[]]$RequiredAssets = @( + 'cdev-cli-win-x64.zip', + 'cdev-cli-linux-x64.tar.gz' + ), + + [Parameter()] + [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] + [string]$RuntimePublishRepository = 'LabVIEW-Community-CI-CD/labview-cdev-cli', + + [Parameter()] + [ValidatePattern('^[A-Za-z0-9._/-]+$')] + [string]$RuntimePublishBranch = 'main', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$RuntimePublishWorkflow = 'publish-cli-runtime-image', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$RuntimeAttestationArtifactName = 'cli-dependency-attestation', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$ExpectedRuntimeRepository = 'ghcr.io/labview-community-ci-cd/labview-cdev-cli-runtime', + + [Parameter()] + [string]$ExpectedRuntimeDigest = '', + + [Parameter()] + [string]$ExpectedRuntimeSourceCommit = '', + + [Parameter()] + [ValidateSet('hard_block', 'warn_only')] + [string]$EnforcementMode = 'hard_block', + + [Parameter()] + [string]$OutputPath = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'lib/WorkflowOps.Common.ps1') + +function Add-ReasonCode { + param( + [Parameter(Mandatory = $true)][AllowEmptyCollection()][System.Collections.Generic.List[string]]$Target, + [Parameter(Mandatory = $true)][string]$ReasonCode + ) + + if (-not $Target.Contains($ReasonCode)) { + [void]$Target.Add($ReasonCode) + } +} + +function Get-RunTimestampUtc { + param([Parameter(Mandatory = $true)][object]$Run) + + $value = [DateTimeOffset]::MinValue + $created = [string]$Run.createdAt + if ([string]::IsNullOrWhiteSpace($created)) { + return $value + } + + if ([DateTimeOffset]::TryParse($created, [ref]$value)) { + return $value.ToUniversalTime() + } + + return [DateTimeOffset]::MinValue +} + +function Convert-RunRecord { + param([Parameter()][object]$Run) + + if ($null -eq $Run) { + return $null + } + + $timestamp = Get-RunTimestampUtc -Run $Run + return [ordered]@{ + run_id = [string]$Run.databaseId + status = [string]$Run.status + conclusion = [string]$Run.conclusion + head_sha = [string]$Run.headSha + event = [string]$Run.event + created_at_utc = if ($timestamp -eq [DateTimeOffset]::MinValue) { '' } else { $timestamp.ToString('o') } + url = [string]$Run.url + } +} + +function Get-ReleaseAssetDigestMap { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][string]$Tag + ) + + $release = Invoke-GhJson -Arguments @('release', 'view', $Tag, '-R', $Repository, '--json', 'assets') + $map = @{} + foreach ($asset in @($release.assets)) { + $name = [string]$asset.name + if (-not [string]::IsNullOrWhiteSpace($name)) { + $map[$name] = [string]$asset.digest + } + } + return $map +} + +$reasonCodes = [System.Collections.Generic.List[string]]::new() +$report = [ordered]@{ + schema_version = '1.0' + timestamp_utc = Get-UtcNowIso + status = 'fail' + enforcement_mode = $EnforcementMode + reason_codes = @() + message = '' + sync_guard_evidence = [ordered]@{ + repository = $SyncGuardRepository + workflow = $SyncGuardWorkflow + branch = $SyncGuardBranch + max_age_hours = $SyncGuardMaxAgeHours + latest_run = $null + latest_completed_run = $null + latest_success_run = $null + latest_success_age_hours = $null + branch_parity = $null + release_parity = $null + asset_parity = @() + } + runtime_evidence = [ordered]@{ + publish_repository = $RuntimePublishRepository + publish_workflow = $RuntimePublishWorkflow + publish_branch = $RuntimePublishBranch + latest_publish_run = $null + attestation_artifact_name = $RuntimeAttestationArtifactName + expected_runtime_repository = $ExpectedRuntimeRepository + expected_runtime_digest = $ExpectedRuntimeDigest + expected_runtime_source_commit = $ExpectedRuntimeSourceCommit + attestation = $null + } +} + +$scratchRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("cli-dependency-gate-" + [Guid]::NewGuid().ToString('N')) +New-Item -Path $scratchRoot -ItemType Directory -Force | Out-Null + +try { + $syncRuns = @( + Get-GhWorkflowRunsPortable ` + -Repository $SyncGuardRepository ` + -Workflow $SyncGuardWorkflow ` + -Branch $SyncGuardBranch ` + -Limit 20 + ) + if (@($syncRuns).Count -gt 0) { + $report.sync_guard_evidence.latest_run = Convert-RunRecord -Run $syncRuns[0] + } + + $latestCompleted = @($syncRuns | Where-Object { [string]$_.status -eq 'completed' } | Select-Object -First 1) + if (@($latestCompleted).Count -eq 1) { + $report.sync_guard_evidence.latest_completed_run = Convert-RunRecord -Run $latestCompleted[0] + if ([string]$latestCompleted[0].conclusion -ne 'success') { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'sync_guard_failed' + } + } + + $latestSuccess = @($syncRuns | Where-Object { [string]$_.status -eq 'completed' -and [string]$_.conclusion -eq 'success' } | Select-Object -First 1) + if (@($latestSuccess).Count -eq 1) { + $successRun = $latestSuccess[0] + $report.sync_guard_evidence.latest_success_run = Convert-RunRecord -Run $successRun + $successTimestamp = Get-RunTimestampUtc -Run $successRun + $ageHours = [Math]::Round((((Get-Date).ToUniversalTime() - $successTimestamp.UtcDateTime).TotalHours), 2) + $report.sync_guard_evidence.latest_success_age_hours = $ageHours + if ($ageHours -gt $SyncGuardMaxAgeHours) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'sync_guard_stale' + } + } else { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'sync_guard_missing' + } + + $upstreamHead = (Invoke-GhText -Arguments @('api', "repos/$SyncGuardRepository/commits/$SyncGuardBranch", '--jq', '.sha')).Trim() + $forkHead = (Invoke-GhText -Arguments @('api', "repos/$ForkRepository/commits/$SyncGuardBranch", '--jq', '.sha')).Trim() + $branchMatches = [string]::Equals($upstreamHead, $forkHead, [System.StringComparison]::Ordinal) + $report.sync_guard_evidence.branch_parity = [ordered]@{ + upstream_head = $upstreamHead + fork_head = $forkHead + matches = $branchMatches + } + if (-not $branchMatches) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'parity_main_head_mismatch' + } + + $upstreamTag = (Invoke-GhText -Arguments @('api', "repos/$SyncGuardRepository/releases/latest", '--jq', '.tag_name')).Trim() + $forkTag = (Invoke-GhText -Arguments @('api', "repos/$ForkRepository/releases/latest", '--jq', '.tag_name')).Trim() + $releaseMatches = [string]::Equals($upstreamTag, $forkTag, [System.StringComparison]::Ordinal) + $report.sync_guard_evidence.release_parity = [ordered]@{ + upstream_latest_tag = $upstreamTag + fork_latest_tag = $forkTag + matches = $releaseMatches + } + if (-not $releaseMatches) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'parity_latest_tag_mismatch' + } + + $upstreamAssetMap = Get-ReleaseAssetDigestMap -Repository $SyncGuardRepository -Tag $upstreamTag + $forkAssetMap = Get-ReleaseAssetDigestMap -Repository $ForkRepository -Tag $forkTag + $assetParity = [System.Collections.Generic.List[object]]::new() + foreach ($asset in @($RequiredAssets)) { + $assetName = ([string]$asset).Trim() + if ([string]::IsNullOrWhiteSpace($assetName)) { + continue + } + + $upstreamDigest = [string]$upstreamAssetMap[$assetName] + $forkDigest = [string]$forkAssetMap[$assetName] + $matches = (-not [string]::IsNullOrWhiteSpace($upstreamDigest)) -and + (-not [string]::IsNullOrWhiteSpace($forkDigest)) -and + [string]::Equals($upstreamDigest, $forkDigest, [System.StringComparison]::Ordinal) + if (-not $matches) { + Add-ReasonCode -Target $reasonCodes -ReasonCode "parity_asset_digest_mismatch:$assetName" + } + + $assetParity.Add([ordered]@{ + asset = $assetName + upstream_digest = $upstreamDigest + fork_digest = $forkDigest + matches = $matches + }) | Out-Null + } + $report.sync_guard_evidence.asset_parity = @($assetParity) + + $publishRuns = @( + Get-GhWorkflowRunsPortable ` + -Repository $RuntimePublishRepository ` + -Workflow $RuntimePublishWorkflow ` + -Branch $RuntimePublishBranch ` + -Limit 10 + ) + $latestPublish = @($publishRuns | Where-Object { [string]$_.status -eq 'completed' } | Select-Object -First 1) + if (@($latestPublish).Count -ne 1) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'runtime_publish_run_missing' + } else { + $publishRun = $latestPublish[0] + $report.runtime_evidence.latest_publish_run = Convert-RunRecord -Run $publishRun + + $attestationRoot = Join-Path $scratchRoot 'attestation' + New-Item -Path $attestationRoot -ItemType Directory -Force | Out-Null + try { + Invoke-Gh -Arguments @( + 'run', + 'download', + [string]$publishRun.databaseId, + '-R', + $RuntimePublishRepository, + '--name', + $RuntimeAttestationArtifactName, + '--dir', + $attestationRoot + ) + } catch { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'runtime_attestation_missing' + } + + $attestationPath = Join-Path $attestationRoot 'cli-dependency-attestation.json' + if (-not (Test-Path -LiteralPath $attestationPath -PathType Leaf)) { + $candidate = Get-ChildItem -Path $attestationRoot -Recurse -Filter 'cli-dependency-attestation.json' -File -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $candidate) { + $attestationPath = $candidate.FullName + } + } + + if (-not (Test-Path -LiteralPath $attestationPath -PathType Leaf)) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'runtime_attestation_missing' + } else { + try { + $attestation = Get-Content -LiteralPath $attestationPath -Raw | ConvertFrom-Json -Depth 30 + $attestationRepository = [string]$attestation.runtime_image.repository + $attestationDigest = [string]$attestation.runtime_image.digest + $attestationSourceCommit = [string]$attestation.source_commit + $attestationStatus = [string]$attestation.status + + $report.runtime_evidence.attestation = [ordered]@{ + path = [System.IO.Path]::GetFullPath($attestationPath) + schema_version = [string]$attestation.schema_version + generated_at_utc = [string]$attestation.generated_at_utc + status = $attestationStatus + source_commit = $attestationSourceCommit + runtime_image_repository = $attestationRepository + runtime_image_digest = $attestationDigest + parity_evidence = $attestation.parity_evidence + sync_guard_evidence = $attestation.sync_guard_evidence + } + + if (-not [string]::IsNullOrWhiteSpace($ExpectedRuntimeRepository) -and + -not [string]::Equals($attestationRepository, $ExpectedRuntimeRepository, [System.StringComparison]::OrdinalIgnoreCase)) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'runtime_repository_mismatch' + } + + if (-not [string]::IsNullOrWhiteSpace($ExpectedRuntimeDigest) -and + -not [string]::Equals($attestationDigest, $ExpectedRuntimeDigest, [System.StringComparison]::OrdinalIgnoreCase)) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'runtime_digest_mismatch' + } + + if (-not [string]::IsNullOrWhiteSpace($ExpectedRuntimeSourceCommit) -and + -not [string]::Equals($attestationSourceCommit, $ExpectedRuntimeSourceCommit, [System.StringComparison]::OrdinalIgnoreCase)) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'runtime_source_commit_mismatch' + } + + if (-not [string]::Equals($attestationStatus, 'in_sync', [System.StringComparison]::OrdinalIgnoreCase)) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'runtime_attestation_reports_drift' + } + } catch { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'runtime_attestation_parse_failed' + } + } + } + + if ($reasonCodes.Count -eq 0) { + $report.status = 'pass' + $report.reason_codes = @('ok') + $report.message = 'CLI dependency gate passed.' + } else { + $report.status = 'fail' + $report.reason_codes = @($reasonCodes) + $report.message = "CLI dependency gate failed. reason_codes=$([string]::Join(',', @($reasonCodes)))" + } +} +catch { + $report.status = 'fail' + $report.reason_codes = @('cli_dependency_gate_runtime_error') + $report.message = [string]$_.Exception.Message +} +finally { + Write-WorkflowOpsReport -Report $report -OutputPath $OutputPath | Out-Null + if (Test-Path -LiteralPath $scratchRoot -PathType Container) { + Remove-Item -LiteralPath $scratchRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if ([string]$report.status -eq 'pass') { + exit 0 +} + +exit 1 diff --git a/scripts/Invoke-ReleaseControlPlane.ps1 b/scripts/Invoke-ReleaseControlPlane.ps1 index e11e080..79cf6e6 100644 --- a/scripts/Invoke-ReleaseControlPlane.ps1 +++ b/scripts/Invoke-ReleaseControlPlane.ps1 @@ -55,6 +55,7 @@ $ErrorActionPreference = 'Stop' $opsSnapshotScript = Join-Path $PSScriptRoot 'Invoke-OpsMonitoringSnapshot.ps1' $opsRemediateScript = Join-Path $PSScriptRoot 'Invoke-OpsAutoRemediation.ps1' +$cliDependencyGateScript = Join-Path $PSScriptRoot 'Invoke-CliDependencyGate.ps1' $dispatchWorkflowScript = Join-Path $PSScriptRoot 'Dispatch-WorkflowAtRemoteHead.ps1' $watchWorkflowScript = Join-Path $PSScriptRoot 'Watch-WorkflowRun.ps1' $canaryHygieneScript = Join-Path $PSScriptRoot 'Invoke-CanarySmokeTagHygiene.ps1' @@ -62,7 +63,7 @@ $rollbackSelfHealingScript = Join-Path $PSScriptRoot 'Invoke-RollbackDrillSelfHe $releaseRunnerLabels = @('self-hosted', 'windows', 'self-hosted-windows-lv') $releaseRunnerLabelsCsv = [string]::Join(',', $releaseRunnerLabels) -foreach ($requiredScript in @($opsSnapshotScript, $opsRemediateScript, $dispatchWorkflowScript, $watchWorkflowScript, $canaryHygieneScript, $rollbackSelfHealingScript)) { +foreach ($requiredScript in @($opsSnapshotScript, $opsRemediateScript, $cliDependencyGateScript, $dispatchWorkflowScript, $watchWorkflowScript, $canaryHygieneScript, $rollbackSelfHealingScript)) { if (-not (Test-Path -LiteralPath $requiredScript -PathType Leaf)) { throw "required_script_missing: $requiredScript" } @@ -605,6 +606,166 @@ function Invoke-ControlPlaneRollbackOrchestration { } } +function Resolve-CliDependencyGatePolicy { + param( + [Parameter(Mandatory = $true)][string]$ManifestPath + ) + + $warnings = [System.Collections.Generic.List[string]]::new() + $policy = [ordered]@{ + enabled = $true + source = 'default' + warnings = @() + hard_block_modes = @('PromotePrerelease', 'PromoteStable', 'FullCycle') + warn_only_modes = @('Validate', 'CanaryCycle') + sync_guard_repository = 'LabVIEW-Community-CI-CD/labview-cdev-cli' + sync_guard_workflow = 'fork-upstream-sync-guard' + sync_guard_branch = 'main' + sync_guard_max_age_hours = 12 + fork_repository = 'svelderrainruiz/labview-cdev-cli' + required_assets = @('cdev-cli-win-x64.zip', 'cdev-cli-linux-x64.tar.gz') + runtime_publish_repository = 'LabVIEW-Community-CI-CD/labview-cdev-cli' + runtime_publish_workflow = 'publish-cli-runtime-image' + runtime_publish_branch = 'main' + runtime_attestation_artifact = 'cli-dependency-attestation' + expected_runtime_repository = 'ghcr.io/labview-community-ci-cd/labview-cdev-cli-runtime' + expected_runtime_digest = '' + expected_runtime_source_commit = '' + } + + if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { + [void]$warnings.Add("workspace_governance_missing: path=$ManifestPath") + $policy.warnings = @($warnings) + return $policy + } + + try { + $manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json -Depth 100 + $releaseClient = $manifest.installer_contract.release_client + $candidatePolicy = $releaseClient.ops_control_plane_policy.cli_dependency_gate + if ($null -eq $candidatePolicy) { + [void]$warnings.Add('cli_dependency_gate_policy_missing') + } else { + $policy.source = 'workspace_governance' + + if ($candidatePolicy.enabled -is [bool]) { + $policy.enabled = [bool]$candidatePolicy.enabled + } + + $candidateHardBlockModes = @($candidatePolicy.hard_block_modes) + if (@($candidateHardBlockModes).Count -gt 0) { + $policy.hard_block_modes = @( + $candidateHardBlockModes | + ForEach-Object { ([string]$_).Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + ) + } + + $candidateWarnOnlyModes = @($candidatePolicy.warn_only_modes) + if (@($candidateWarnOnlyModes).Count -gt 0) { + $policy.warn_only_modes = @( + $candidateWarnOnlyModes | + ForEach-Object { ([string]$_).Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + ) + } + + if (-not [string]::IsNullOrWhiteSpace([string]$candidatePolicy.sync_guard_repository)) { + $policy.sync_guard_repository = ([string]$candidatePolicy.sync_guard_repository).Trim() + } + if (-not [string]::IsNullOrWhiteSpace([string]$candidatePolicy.sync_guard_workflow)) { + $policy.sync_guard_workflow = ([string]$candidatePolicy.sync_guard_workflow).Trim() + } + if (-not [string]::IsNullOrWhiteSpace([string]$candidatePolicy.sync_guard_branch)) { + $policy.sync_guard_branch = ([string]$candidatePolicy.sync_guard_branch).Trim() + } + + $candidateSyncGuardMaxAgeHours = 0 + if ([int]::TryParse([string]$candidatePolicy.sync_guard_max_age_hours, [ref]$candidateSyncGuardMaxAgeHours) -and + $candidateSyncGuardMaxAgeHours -ge 1 -and $candidateSyncGuardMaxAgeHours -le 168) { + $policy.sync_guard_max_age_hours = $candidateSyncGuardMaxAgeHours + } + + if (-not [string]::IsNullOrWhiteSpace([string]$candidatePolicy.fork_repository)) { + $policy.fork_repository = ([string]$candidatePolicy.fork_repository).Trim() + } + + $candidateRequiredAssets = @($candidatePolicy.required_assets) + if (@($candidateRequiredAssets).Count -gt 0) { + $policy.required_assets = @( + $candidateRequiredAssets | + ForEach-Object { ([string]$_).Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + ) + } + + if (-not [string]::IsNullOrWhiteSpace([string]$candidatePolicy.runtime_publish_repository)) { + $policy.runtime_publish_repository = ([string]$candidatePolicy.runtime_publish_repository).Trim() + } + if (-not [string]::IsNullOrWhiteSpace([string]$candidatePolicy.runtime_publish_workflow)) { + $policy.runtime_publish_workflow = ([string]$candidatePolicy.runtime_publish_workflow).Trim() + } + if (-not [string]::IsNullOrWhiteSpace([string]$candidatePolicy.runtime_publish_branch)) { + $policy.runtime_publish_branch = ([string]$candidatePolicy.runtime_publish_branch).Trim() + } + if (-not [string]::IsNullOrWhiteSpace([string]$candidatePolicy.runtime_attestation_artifact)) { + $policy.runtime_attestation_artifact = ([string]$candidatePolicy.runtime_attestation_artifact).Trim() + } + } + + if ($null -ne $releaseClient.cdev_cli_sync) { + if (-not [string]::IsNullOrWhiteSpace([string]$releaseClient.cdev_cli_sync.primary_repo)) { + $policy.fork_repository = ([string]$releaseClient.cdev_cli_sync.primary_repo).Trim() + } + if (-not [string]::IsNullOrWhiteSpace([string]$releaseClient.cdev_cli_sync.mirror_repo)) { + $policy.sync_guard_repository = ([string]$releaseClient.cdev_cli_sync.mirror_repo).Trim() + $policy.runtime_publish_repository = ([string]$releaseClient.cdev_cli_sync.mirror_repo).Trim() + } + } + + if ($null -ne $releaseClient.runtime_images -and $null -ne $releaseClient.runtime_images.cdev_cli_runtime) { + if (-not [string]::IsNullOrWhiteSpace([string]$releaseClient.runtime_images.cdev_cli_runtime.canonical_repository)) { + $policy.expected_runtime_repository = ([string]$releaseClient.runtime_images.cdev_cli_runtime.canonical_repository).Trim() + } + if (-not [string]::IsNullOrWhiteSpace([string]$releaseClient.runtime_images.cdev_cli_runtime.digest)) { + $policy.expected_runtime_digest = ([string]$releaseClient.runtime_images.cdev_cli_runtime.digest).Trim() + } + if (-not [string]::IsNullOrWhiteSpace([string]$releaseClient.runtime_images.cdev_cli_runtime.source_commit)) { + $policy.expected_runtime_source_commit = ([string]$releaseClient.runtime_images.cdev_cli_runtime.source_commit).Trim() + } + } + } catch { + [void]$warnings.Add("cli_dependency_gate_policy_load_failed: $([string]$_.Exception.Message)") + } + + $policy.warnings = @($warnings) + return $policy +} + +function Resolve-CliDependencyGateEnforcementMode { + param( + [Parameter(Mandatory = $true)][string]$ModeName, + [Parameter(Mandatory = $true)][object]$Policy + ) + + if (-not [bool]$Policy.enabled) { + return 'warn_only' + } + + if (@($Policy.hard_block_modes) -contains $ModeName) { + return 'hard_block' + } + + if (@($Policy.warn_only_modes) -contains $ModeName) { + return 'warn_only' + } + + return 'warn_only' +} + $defaultSemverOnlyEnforceUtc = [DateTimeOffset]::Parse('2026-07-01T00:00:00Z') $workspaceGovernancePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'workspace-governance.json' $gaPolicy = Resolve-ControlPlaneGaPolicy -ManifestPath $workspaceGovernancePath @@ -637,6 +798,11 @@ foreach ($warning in @($stablePromotionWindowPolicy.warnings)) { Write-Warning "[stable_promotion_window_policy_warning] $warning" } +$script:cliDependencyGatePolicy = Resolve-CliDependencyGatePolicy -ManifestPath $workspaceGovernancePath +foreach ($warning in @($script:cliDependencyGatePolicy.warnings)) { + Write-Warning "[cli_dependency_gate_policy_warning] $warning" +} + $script:releaseRequiredAssets = @( 'lvie-cdev-workspace-installer.exe', 'lvie-cdev-workspace-installer.exe.sha256', @@ -668,6 +834,7 @@ function Resolve-ControlPlaneFailureReasonCode { if ($message -match '^promotion_source_not_at_head') { return 'promotion_source_not_at_head' } if ($message -match '^promotion_lineage_invalid') { return 'promotion_lineage_invalid' } if ($message -match '^stable_window_override_') { return 'stable_window_override_invalid' } + if ($message -match '^cli_dependency_gate_failed') { return 'cli_dependency_gate_failed' } if ($message -match '^branch_head_unresolved') { return 'branch_head_unresolved' } if ($message -match '^semver_prerelease_sequence_exhausted') { return 'semver_prerelease_sequence_exhausted' } if ($message -match '^release_tag_collision_retry_exhausted') { return 'release_tag_collision_retry_exhausted' } @@ -1968,6 +2135,14 @@ $report = [ordered]@{ reason_code = 'not_full_cycle_mode' } } + cli_dependency_gate = [ordered]@{ + status = 'not_run' + enforcement_mode = Resolve-CliDependencyGateEnforcementMode -ModeName $Mode -Policy $script:cliDependencyGatePolicy + reason_codes = @() + message = '' + sync_guard_evidence = $null + runtime_evidence = $null + } status = 'fail' reason_code = '' message = '' @@ -2052,6 +2227,61 @@ try { if ([string]$report.post_health.status -ne 'pass') { throw "ops_unhealthy: reason_codes=$([string]::Join(',', @($report.post_health.reason_codes)))" } + + if ([bool]$script:cliDependencyGatePolicy.enabled) { + $cliDependencyGatePath = Join-Path $scratchRoot 'cli-dependency-gate.json' + $cliSyncGuardMaxAgeHours = [Math]::Min([int]$SyncGuardMaxAgeHours, [int]$script:cliDependencyGatePolicy.sync_guard_max_age_hours) + $cliDependencyGateExitCode = 0 + try { + & pwsh -NoProfile -File $cliDependencyGateScript ` + -SyncGuardRepository ([string]$script:cliDependencyGatePolicy.sync_guard_repository) ` + -ForkRepository ([string]$script:cliDependencyGatePolicy.fork_repository) ` + -SyncGuardBranch ([string]$script:cliDependencyGatePolicy.sync_guard_branch) ` + -SyncGuardWorkflow ([string]$script:cliDependencyGatePolicy.sync_guard_workflow) ` + -SyncGuardMaxAgeHours $cliSyncGuardMaxAgeHours ` + -RequiredAssets @($script:cliDependencyGatePolicy.required_assets) ` + -RuntimePublishRepository ([string]$script:cliDependencyGatePolicy.runtime_publish_repository) ` + -RuntimePublishBranch ([string]$script:cliDependencyGatePolicy.runtime_publish_branch) ` + -RuntimePublishWorkflow ([string]$script:cliDependencyGatePolicy.runtime_publish_workflow) ` + -RuntimeAttestationArtifactName ([string]$script:cliDependencyGatePolicy.runtime_attestation_artifact) ` + -ExpectedRuntimeRepository ([string]$script:cliDependencyGatePolicy.expected_runtime_repository) ` + -ExpectedRuntimeDigest ([string]$script:cliDependencyGatePolicy.expected_runtime_digest) ` + -ExpectedRuntimeSourceCommit ([string]$script:cliDependencyGatePolicy.expected_runtime_source_commit) ` + -EnforcementMode ([string]$report.cli_dependency_gate.enforcement_mode) ` + -OutputPath $cliDependencyGatePath + $cliDependencyGateExitCode = if ($null -eq $LASTEXITCODE) { 0 } else { [int]$LASTEXITCODE } + } catch { + $cliDependencyGateExitCode = if ($null -eq $LASTEXITCODE) { 1 } else { [int]$LASTEXITCODE } + } + + if (Test-Path -LiteralPath $cliDependencyGatePath -PathType Leaf) { + $cliDependencyGateReport = Get-Content -LiteralPath $cliDependencyGatePath -Raw | ConvertFrom-Json -ErrorAction Stop + $report.cli_dependency_gate.status = [string]$cliDependencyGateReport.status + $report.cli_dependency_gate.reason_codes = @($cliDependencyGateReport.reason_codes) + $report.cli_dependency_gate.message = [string]$cliDependencyGateReport.message + $report.cli_dependency_gate.sync_guard_evidence = $cliDependencyGateReport.sync_guard_evidence + $report.cli_dependency_gate.runtime_evidence = $cliDependencyGateReport.runtime_evidence + } else { + $report.cli_dependency_gate.status = 'fail' + $report.cli_dependency_gate.reason_codes = @('cli_dependency_gate_report_missing') + $report.cli_dependency_gate.message = "CLI dependency gate report missing: $cliDependencyGatePath" + } + + $cliDependencyGateFailed = ($cliDependencyGateExitCode -ne 0) -or ([string]$report.cli_dependency_gate.status -eq 'fail') + if ($cliDependencyGateFailed -and [string]$report.cli_dependency_gate.enforcement_mode -eq 'hard_block') { + $joinedReasonCodes = [string]::Join(',', @($report.cli_dependency_gate.reason_codes)) + throw "cli_dependency_gate_failed: reason_codes=$joinedReasonCodes" + } + + if ($cliDependencyGateFailed -and [string]$report.cli_dependency_gate.enforcement_mode -eq 'warn_only') { + $report.cli_dependency_gate.status = 'warn' + } + } else { + $report.cli_dependency_gate.status = 'skipped' + $report.cli_dependency_gate.reason_codes = @('cli_dependency_gate_disabled') + $report.cli_dependency_gate.message = 'CLI dependency gate is disabled by policy.' + } + Add-ControlPlaneStateTransition ` -StateMachine $report.state_machine ` -FromState 'ops_health_verify' ` diff --git a/scripts/Test-PolicyContracts.ps1 b/scripts/Test-PolicyContracts.ps1 index f4293ee..f9501dd 100644 --- a/scripts/Test-PolicyContracts.ps1 +++ b/scripts/Test-PolicyContracts.ps1 @@ -169,6 +169,14 @@ if ($installerContractMembers -contains 'release_client') { Add-Check -Scope 'manifest' -Name 'release_client_runtime_images_ops_base_repository' -Passed ([string]$releaseClient.runtime_images.ops_runtime.base_repository -eq 'ghcr.io/labview-community-ci-cd/labview-cdev-cli-runtime') -Detail ([string]$releaseClient.runtime_images.ops_runtime.base_repository) Add-Check -Scope 'manifest' -Name 'release_client_runtime_images_ops_base_digest' -Passed ([string]$releaseClient.runtime_images.ops_runtime.base_digest -eq 'sha256:0506e8789680ce1c941ca9f005b75d804150aed6ad36a5ac59458b802d358423') -Detail ([string]$releaseClient.runtime_images.ops_runtime.base_digest) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_exists' -Passed ($null -ne $releaseClient.ops_control_plane_policy) -Detail 'installer_contract.release_client.ops_control_plane_policy' + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_exists' -Passed ($null -ne $releaseClient.ops_control_plane_policy.cli_dependency_gate) -Detail 'installer_contract.release_client.ops_control_plane_policy.cli_dependency_gate' + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_hard_block_promote_prerelease' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) -contains 'PromotePrerelease') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_hard_block_promote_stable' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) -contains 'PromoteStable') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_hard_block_full_cycle' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) -contains 'FullCycle') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_warn_only_validate' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes) -contains 'Validate') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_warn_only_canary_cycle' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes) -contains 'CanaryCycle') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_runtime_publish_workflow' -Passed ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_publish_workflow -eq 'publish-cli-runtime-image') -Detail ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_publish_workflow) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_runtime_attestation_artifact' -Passed ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_attestation_artifact -eq 'cli-dependency-attestation') -Detail ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_attestation_artifact) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_schema_version' -Passed ([string]$releaseClient.ops_control_plane_policy.schema_version -eq '2.0') -Detail ([string]$releaseClient.ops_control_plane_policy.schema_version) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_lookback_days' -Passed ([int]$releaseClient.ops_control_plane_policy.slo_gate.lookback_days -eq 7) -Detail ([string]$releaseClient.ops_control_plane_policy.slo_gate.lookback_days) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_min_success_rate_pct' -Passed ([double]$releaseClient.ops_control_plane_policy.slo_gate.min_success_rate_pct -eq 100) -Detail ([string]$releaseClient.ops_control_plane_policy.slo_gate.min_success_rate_pct) diff --git a/scripts/Test-ReleaseClientContracts.ps1 b/scripts/Test-ReleaseClientContracts.ps1 index 3b377e5..ec95b79 100644 --- a/scripts/Test-ReleaseClientContracts.ps1 +++ b/scripts/Test-ReleaseClientContracts.ps1 @@ -132,6 +132,16 @@ if ($null -ne $releaseClient) { Add-Check -Name 'ops_policy_tag_strategy_legacy_tag_family' -Passed ([string]$releaseClient.ops_control_plane_policy.tag_strategy.legacy_tag_family -eq 'legacy_date_window') -Detail ([string]$releaseClient.ops_control_plane_policy.tag_strategy.legacy_tag_family) Add-Check -Name 'ops_policy_tag_strategy_semver_only_enforce' -Passed (([DateTime]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -eq '2026-07-01T00:00:00Z') -Detail ([string]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc) Add-Check -Name 'ops_policy_tag_strategy_matches_signature_grace_end' -Passed (([DateTime]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -eq ([DateTime]$releaseClient.signature_policy.grace_end_utc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) -Detail ("semver_only_enforce_utc={0}; signature_grace_end_utc={1}" -f [string]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc, [string]$releaseClient.signature_policy.grace_end_utc) + Add-Check -Name 'ops_policy_cli_dependency_gate_enabled' -Passed ([bool]$releaseClient.ops_control_plane_policy.cli_dependency_gate.enabled) -Detail ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.enabled) + Add-Check -Name 'ops_policy_cli_dependency_gate_hard_block_modes_promote_prerelease' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) -contains 'PromotePrerelease') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes))) + Add-Check -Name 'ops_policy_cli_dependency_gate_hard_block_modes_promote_stable' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) -contains 'PromoteStable') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes))) + Add-Check -Name 'ops_policy_cli_dependency_gate_hard_block_modes_full_cycle' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) -contains 'FullCycle') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes))) + Add-Check -Name 'ops_policy_cli_dependency_gate_warn_only_modes_validate' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes) -contains 'Validate') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes))) + Add-Check -Name 'ops_policy_cli_dependency_gate_warn_only_modes_canary_cycle' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes) -contains 'CanaryCycle') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes))) + Add-Check -Name 'ops_policy_cli_dependency_gate_runtime_publish_workflow' -Passed ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_publish_workflow -eq 'publish-cli-runtime-image') -Detail ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_publish_workflow) + Add-Check -Name 'ops_policy_cli_dependency_gate_attestation_artifact' -Passed ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_attestation_artifact -eq 'cli-dependency-attestation') -Detail ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_attestation_artifact) + Add-Check -Name 'ops_policy_cli_dependency_gate_required_asset_win' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.required_assets) -contains 'cdev-cli-win-x64.zip') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.required_assets))) + Add-Check -Name 'ops_policy_cli_dependency_gate_required_asset_linux' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.required_assets) -contains 'cdev-cli-linux-x64.tar.gz') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.required_assets))) Add-Check -Name 'ops_policy_stable_window_full_cycle_weekday_monday' -Passed (@($releaseClient.ops_control_plane_policy.stable_promotion_window.full_cycle_allowed_utc_weekdays) -contains 'Monday') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.stable_promotion_window.full_cycle_allowed_utc_weekdays))) Add-Check -Name 'ops_policy_stable_window_allow_override' -Passed ([bool]$releaseClient.ops_control_plane_policy.stable_promotion_window.allow_outside_window_with_override) -Detail ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.allow_outside_window_with_override) Add-Check -Name 'ops_policy_stable_window_reason_required' -Passed ([bool]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_required) -Detail ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_required) diff --git a/scripts/Test-ReleaseControlPlanePolicyDrift.ps1 b/scripts/Test-ReleaseControlPlanePolicyDrift.ps1 index 9899134..097fc54 100644 --- a/scripts/Test-ReleaseControlPlanePolicyDrift.ps1 +++ b/scripts/Test-ReleaseControlPlanePolicyDrift.ps1 @@ -191,6 +191,42 @@ try { } } + $cliDependencyGatePresent = ($null -ne $releaseClient.ops_control_plane_policy.cli_dependency_gate) + $checks.Add([ordered]@{ + check = 'release_client_ops_control_plane_policy_cli_dependency_gate_present' + passed = $cliDependencyGatePresent + }) | Out-Null + if (-not $cliDependencyGatePresent) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'ops_control_plane_cli_dependency_gate_missing' + } else { + $cliGateHardBlockModesPresent = (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes).Count -gt 0) + $checks.Add([ordered]@{ + check = 'release_client_ops_control_plane_policy_cli_dependency_gate_hard_block_modes_present' + passed = $cliGateHardBlockModesPresent + }) | Out-Null + if (-not $cliGateHardBlockModesPresent) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'ops_control_plane_cli_dependency_gate_hard_block_modes_missing' + } + + $cliGateWarnOnlyModesPresent = (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes).Count -gt 0) + $checks.Add([ordered]@{ + check = 'release_client_ops_control_plane_policy_cli_dependency_gate_warn_only_modes_present' + passed = $cliGateWarnOnlyModesPresent + }) | Out-Null + if (-not $cliGateWarnOnlyModesPresent) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'ops_control_plane_cli_dependency_gate_warn_only_modes_missing' + } + + $cliGateRuntimeWorkflowPresent = (-not [string]::IsNullOrWhiteSpace([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_publish_workflow)) + $checks.Add([ordered]@{ + check = 'release_client_ops_control_plane_policy_cli_dependency_gate_runtime_publish_workflow_present' + passed = $cliGateRuntimeWorkflowPresent + }) | Out-Null + if (-not $cliGateRuntimeWorkflowPresent) { + Add-ReasonCode -Target $reasonCodes -ReasonCode 'ops_control_plane_cli_dependency_gate_runtime_publish_workflow_missing' + } + } + $stableWindowPresent = ($null -ne $releaseClient.ops_control_plane_policy.stable_promotion_window) $checks.Add([ordered]@{ check = 'release_client_ops_control_plane_policy_stable_window_present' diff --git a/scripts/Write-ReleaseControlPlaneDecisionTrail.ps1 b/scripts/Write-ReleaseControlPlaneDecisionTrail.ps1 index f6ab963..0430273 100644 --- a/scripts/Write-ReleaseControlPlaneDecisionTrail.ps1 +++ b/scripts/Write-ReleaseControlPlaneDecisionTrail.ps1 @@ -127,6 +127,7 @@ $executionSummaries = @( $stablePromotionWindow = Get-OptionalPropertyValue -Object $report -Name 'stable_promotion_window' -Default $null $stableWindowDecision = Get-OptionalPropertyValue -Object $stablePromotionWindow -Name 'decision' -Default $null +$cliDependencyGate = Get-OptionalPropertyValue -Object $report -Name 'cli_dependency_gate' -Default $null $decisionTrail = [ordered]@{ schema_version = '1.0' @@ -159,6 +160,16 @@ $decisionTrail = [ordered]@{ can_promote = [bool](Get-OptionalPropertyValue -Object $stableWindowDecision -Name 'can_promote' -Default $false) current_utc_weekday = [string](Get-OptionalPropertyValue -Object $stableWindowDecision -Name 'current_utc_weekday' -Default '') } + cli_dependency_gate = [ordered]@{ + status = [string](Get-OptionalPropertyValue -Object $cliDependencyGate -Name 'status' -Default 'not_run') + enforcement_mode = [string](Get-OptionalPropertyValue -Object $cliDependencyGate -Name 'enforcement_mode' -Default '') + reason_codes = @( + @(Get-OptionalPropertyValue -Object $cliDependencyGate -Name 'reason_codes' -Default @()) | + ForEach-Object { [string]$_ } + ) + sync_guard_evidence = Get-OptionalPropertyValue -Object $cliDependencyGate -Name 'sync_guard_evidence' -Default $null + runtime_evidence = Get-OptionalPropertyValue -Object $cliDependencyGate -Name 'runtime_evidence' -Default $null + } executions = @($executionSummaries) } } @@ -171,6 +182,8 @@ $fingerprintPayload = [ordered]@{ state_machine_current_state = if ($null -eq $decisionTrail.decision_evidence.state_machine) { '' } else { [string]$decisionTrail.decision_evidence.state_machine.current_state } rollback_status = if ($null -eq $decisionTrail.decision_evidence.rollback_orchestration) { '' } else { [string]$decisionTrail.decision_evidence.rollback_orchestration.status } rollback_reason_code = if ($null -eq $decisionTrail.decision_evidence.rollback_orchestration) { '' } else { [string]$decisionTrail.decision_evidence.rollback_orchestration.reason_code } + cli_dependency_gate_status = [string]$decisionTrail.decision_evidence.cli_dependency_gate.status + cli_dependency_gate_reason_codes = [string]::Join(',', @($decisionTrail.decision_evidence.cli_dependency_gate.reason_codes)) } $decisionTrail.signature = [ordered]@{ diff --git a/tests/ReleaseClientDependencyGateContract.Tests.ps1 b/tests/ReleaseClientDependencyGateContract.Tests.ps1 new file mode 100644 index 0000000..d6e9646 --- /dev/null +++ b/tests/ReleaseClientDependencyGateContract.Tests.ps1 @@ -0,0 +1,45 @@ +#Requires -Version 7.0 +#Requires -Modules Pester + +$ErrorActionPreference = 'Stop' + +Describe 'Release client dependency gate contract' { + BeforeAll { + $script:repoRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..')).Path + $script:scriptPath = Join-Path $script:repoRoot 'scripts/Invoke-CliDependencyGate.ps1' + + if (-not (Test-Path -LiteralPath $script:scriptPath -PathType Leaf)) { + throw "CLI dependency gate script missing: $script:scriptPath" + } + + $script:content = Get-Content -LiteralPath $script:scriptPath -Raw + } + + It 'defines required evidence fields and deterministic reason-code model' { + $script:content | Should -Match 'sync_guard_evidence' + $script:content | Should -Match 'runtime_evidence' + $script:content | Should -Match 'enforcement_mode' + $script:content | Should -Match 'hard_block' + $script:content | Should -Match 'warn_only' + $script:content | Should -Match 'parity_main_head_mismatch' + $script:content | Should -Match 'parity_latest_tag_mismatch' + $script:content | Should -Match 'parity_asset_digest_mismatch' + $script:content | Should -Match 'runtime_attestation_missing' + $script:content | Should -Match 'runtime_digest_mismatch' + $script:content | Should -Match 'runtime_source_commit_mismatch' + } + + It 'uses portable workflow ops helpers and emits structured report output' { + $script:content | Should -Match 'WorkflowOps\.Common\.ps1' + $script:content | Should -Match 'Get-GhWorkflowRunsPortable' + $script:content | Should -Match 'Write-WorkflowOpsReport' + $script:content | Should -Match 'cli_dependency_gate_runtime_error' + } + + It 'has parse-safe PowerShell syntax' { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseInput($script:content, [ref]$tokens, [ref]$errors) + @($errors).Count | Should -Be 0 + } +} diff --git a/tests/ReleaseClientPolicyContract.Tests.ps1 b/tests/ReleaseClientPolicyContract.Tests.ps1 index 0d05cab..6975c93 100644 --- a/tests/ReleaseClientPolicyContract.Tests.ps1 +++ b/tests/ReleaseClientPolicyContract.Tests.ps1 @@ -91,6 +91,18 @@ Describe 'Release client policy contract' { $releaseClient.ops_control_plane_policy.tag_strategy.legacy_tag_family | Should -Be 'legacy_date_window' ([DateTime]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') | Should -Be '2026-07-01T00:00:00Z' ([DateTime]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') | Should -Be (([DateTime]$releaseClient.signature_policy.grace_end_utc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) + $releaseClient.ops_control_plane_policy.cli_dependency_gate.enabled | Should -BeTrue + @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) | Should -Contain 'PromotePrerelease' + @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) | Should -Contain 'PromoteStable' + @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) | Should -Contain 'FullCycle' + @($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes) | Should -Contain 'Validate' + @($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes) | Should -Contain 'CanaryCycle' + $releaseClient.ops_control_plane_policy.cli_dependency_gate.sync_guard_repository | Should -Be 'LabVIEW-Community-CI-CD/labview-cdev-cli' + $releaseClient.ops_control_plane_policy.cli_dependency_gate.fork_repository | Should -Be 'svelderrainruiz/labview-cdev-cli' + $releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_publish_workflow | Should -Be 'publish-cli-runtime-image' + $releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_attestation_artifact | Should -Be 'cli-dependency-attestation' + @($releaseClient.ops_control_plane_policy.cli_dependency_gate.required_assets) | Should -Contain 'cdev-cli-win-x64.zip' + @($releaseClient.ops_control_plane_policy.cli_dependency_gate.required_assets) | Should -Contain 'cdev-cli-linux-x64.tar.gz' @($releaseClient.ops_control_plane_policy.stable_promotion_window.full_cycle_allowed_utc_weekdays) | Should -Contain 'Monday' $releaseClient.ops_control_plane_policy.stable_promotion_window.allow_outside_window_with_override | Should -BeTrue $releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_required | Should -BeTrue @@ -147,6 +159,9 @@ Describe 'Release client policy contract' { $script:policyScriptContent | Should -Match 'ops_policy_decision_trail_schema_version' $script:policyScriptContent | Should -Match 'ops_policy_decision_trail_hash_algorithm' $script:policyScriptContent | Should -Match 'ops_policy_tag_strategy_semver_only_enforce' + $script:policyScriptContent | Should -Match 'ops_policy_cli_dependency_gate_enabled' + $script:policyScriptContent | Should -Match 'ops_policy_cli_dependency_gate_runtime_publish_workflow' + $script:policyScriptContent | Should -Match 'ops_policy_cli_dependency_gate_attestation_artifact' $script:policyScriptContent | Should -Match 'ops_policy_stable_window_full_cycle_weekday_monday' $script:policyScriptContent | Should -Match 'ops_policy_stable_window_reason_pattern_exists' $script:policyScriptContent | Should -Match 'ops_policy_stable_window_reason_example' diff --git a/tests/ReleaseControlPlaneDecisionTrailContract.Tests.ps1 b/tests/ReleaseControlPlaneDecisionTrailContract.Tests.ps1 index a310ae8..3232ae8 100644 --- a/tests/ReleaseControlPlaneDecisionTrailContract.Tests.ps1 +++ b/tests/ReleaseControlPlaneDecisionTrailContract.Tests.ps1 @@ -22,6 +22,9 @@ Describe 'Release control plane decision trail contract' { $script:scriptContent | Should -Match 'state_machine' $script:scriptContent | Should -Match 'rollback_orchestration' $script:scriptContent | Should -Match 'stable_window_decision' + $script:scriptContent | Should -Match 'cli_dependency_gate' + $script:scriptContent | Should -Match 'cli_dependency_gate_status' + $script:scriptContent | Should -Match 'cli_dependency_gate_reason_codes' $script:scriptContent | Should -Match 'signature' $script:scriptContent | Should -Match 'fingerprint' $script:scriptContent | Should -Match 'Write-WorkflowOpsReport' diff --git a/tests/ReleaseControlPlaneWorkflowContract.Tests.ps1 b/tests/ReleaseControlPlaneWorkflowContract.Tests.ps1 index 6fc2345..3a349ce 100644 --- a/tests/ReleaseControlPlaneWorkflowContract.Tests.ps1 +++ b/tests/ReleaseControlPlaneWorkflowContract.Tests.ps1 @@ -65,9 +65,16 @@ Describe 'Release control plane workflow contract' { $script:runtimeContent | Should -Match 'Resolve-SemVerEnforcementPolicy' $script:runtimeContent | Should -Match 'Resolve-StablePromotionWindowPolicy' $script:runtimeContent | Should -Match 'Resolve-ControlPlaneGaPolicy' + $script:runtimeContent | Should -Match 'Resolve-CliDependencyGatePolicy' + $script:runtimeContent | Should -Match 'Resolve-CliDependencyGateEnforcementMode' $script:runtimeContent | Should -Match 'Add-ControlPlaneStateTransition' $script:runtimeContent | Should -Match 'Should-AttemptRollbackOrchestration' $script:runtimeContent | Should -Match 'Invoke-ControlPlaneRollbackOrchestration' + $script:runtimeContent | Should -Match 'Invoke-CliDependencyGate\.ps1' + $script:runtimeContent | Should -Match 'cli_dependency_gate' + $script:runtimeContent | Should -Match 'hard_block' + $script:runtimeContent | Should -Match 'warn_only' + $script:runtimeContent | Should -Match 'cli_dependency_gate_failed' $script:runtimeContent | Should -Match 'Resolve-StablePromotionWindowDecision' $script:runtimeContent | Should -Match 'Write-StableOverrideAuditReport' $script:runtimeContent | Should -Match 'Resolve-ControlPlaneFailureReasonCode' diff --git a/tests/WorkspaceSurfaceContract.Tests.ps1 b/tests/WorkspaceSurfaceContract.Tests.ps1 index 976ff07..5d4402f 100644 --- a/tests/WorkspaceSurfaceContract.Tests.ps1 +++ b/tests/WorkspaceSurfaceContract.Tests.ps1 @@ -40,6 +40,7 @@ Describe 'Workspace surface contract' { $script:raceHardeningDrillScriptPath = Join-Path $script:repoRoot 'scripts/Invoke-ReleaseRaceHardeningDrill.ps1' $script:raceHardeningGateScriptPath = Join-Path $script:repoRoot 'scripts/Test-ReleaseRaceHardeningGate.ps1' $script:releaseGuardrailsSelfHealingScriptPath = Join-Path $script:repoRoot 'scripts/Invoke-ReleaseGuardrailsSelfHealing.ps1' + $script:cliDependencyGateScriptPath = Join-Path $script:repoRoot 'scripts/Invoke-CliDependencyGate.ps1' $script:releaseBranchProtectionPolicyScriptPath = Join-Path $script:repoRoot 'scripts/Test-ReleaseBranchProtectionPolicy.ps1' $script:setReleaseBranchProtectionPolicyScriptPath = Join-Path $script:repoRoot 'scripts/Set-ReleaseBranchProtectionPolicy.ps1' $script:dockerLinuxIterationScriptPath = Join-Path $script:repoRoot 'scripts/Invoke-DockerDesktopLinuxIteration.ps1' @@ -118,6 +119,7 @@ Describe 'Workspace surface contract' { $script:raceHardeningDrillScriptPath, $script:raceHardeningGateScriptPath, $script:releaseGuardrailsSelfHealingScriptPath, + $script:cliDependencyGateScriptPath, $script:releaseBranchProtectionPolicyScriptPath, $script:setReleaseBranchProtectionPolicyScriptPath, $script:dockerLinuxIterationScriptPath, diff --git a/workspace-governance-payload/workspace-governance/scripts/Test-PolicyContracts.ps1 b/workspace-governance-payload/workspace-governance/scripts/Test-PolicyContracts.ps1 index 63575aa..f9501dd 100644 --- a/workspace-governance-payload/workspace-governance/scripts/Test-PolicyContracts.ps1 +++ b/workspace-governance-payload/workspace-governance/scripts/Test-PolicyContracts.ps1 @@ -169,19 +169,71 @@ if ($installerContractMembers -contains 'release_client') { Add-Check -Scope 'manifest' -Name 'release_client_runtime_images_ops_base_repository' -Passed ([string]$releaseClient.runtime_images.ops_runtime.base_repository -eq 'ghcr.io/labview-community-ci-cd/labview-cdev-cli-runtime') -Detail ([string]$releaseClient.runtime_images.ops_runtime.base_repository) Add-Check -Scope 'manifest' -Name 'release_client_runtime_images_ops_base_digest' -Passed ([string]$releaseClient.runtime_images.ops_runtime.base_digest -eq 'sha256:0506e8789680ce1c941ca9f005b75d804150aed6ad36a5ac59458b802d358423') -Detail ([string]$releaseClient.runtime_images.ops_runtime.base_digest) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_exists' -Passed ($null -ne $releaseClient.ops_control_plane_policy) -Detail 'installer_contract.release_client.ops_control_plane_policy' + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_exists' -Passed ($null -ne $releaseClient.ops_control_plane_policy.cli_dependency_gate) -Detail 'installer_contract.release_client.ops_control_plane_policy.cli_dependency_gate' + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_hard_block_promote_prerelease' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) -contains 'PromotePrerelease') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_hard_block_promote_stable' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) -contains 'PromoteStable') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_hard_block_full_cycle' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes) -contains 'FullCycle') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.hard_block_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_warn_only_validate' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes) -contains 'Validate') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_warn_only_canary_cycle' -Passed (@($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes) -contains 'CanaryCycle') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.cli_dependency_gate.warn_only_modes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_runtime_publish_workflow' -Passed ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_publish_workflow -eq 'publish-cli-runtime-image') -Detail ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_publish_workflow) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_cli_dependency_gate_runtime_attestation_artifact' -Passed ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_attestation_artifact -eq 'cli-dependency-attestation') -Detail ([string]$releaseClient.ops_control_plane_policy.cli_dependency_gate.runtime_attestation_artifact) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_schema_version' -Passed ([string]$releaseClient.ops_control_plane_policy.schema_version -eq '2.0') -Detail ([string]$releaseClient.ops_control_plane_policy.schema_version) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_lookback_days' -Passed ([int]$releaseClient.ops_control_plane_policy.slo_gate.lookback_days -eq 7) -Detail ([string]$releaseClient.ops_control_plane_policy.slo_gate.lookback_days) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_min_success_rate_pct' -Passed ([double]$releaseClient.ops_control_plane_policy.slo_gate.min_success_rate_pct -eq 100) -Detail ([string]$releaseClient.ops_control_plane_policy.slo_gate.min_success_rate_pct) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_max_sync_guard_age_hours' -Passed ([int]$releaseClient.ops_control_plane_policy.slo_gate.max_sync_guard_age_hours -eq 12) -Detail ([string]$releaseClient.ops_control_plane_policy.slo_gate.max_sync_guard_age_hours) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_alert_thresholds_warning_min_success_rate_pct' -Passed ([double]$releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.warning_min_success_rate_pct -eq 99.5) -Detail ([string]$releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.warning_min_success_rate_pct) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_alert_thresholds_critical_min_success_rate_pct' -Passed ([double]$releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_min_success_rate_pct -eq 99) -Detail ([string]$releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_min_success_rate_pct) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_alert_thresholds_warning_reason_workflow_missing_runs' -Passed (@($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.warning_reason_codes) -contains 'workflow_missing_runs') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.warning_reason_codes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_alert_thresholds_warning_reason_workflow_success_rate_below_threshold' -Passed (@($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.warning_reason_codes) -contains 'workflow_success_rate_below_threshold') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.warning_reason_codes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_alert_thresholds_critical_reason_workflow_failure_detected' -Passed (@($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_reason_codes) -contains 'workflow_failure_detected') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_reason_codes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_alert_thresholds_critical_reason_sync_guard_missing' -Passed (@($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_reason_codes) -contains 'sync_guard_missing') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_reason_codes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_alert_thresholds_critical_reason_sync_guard_stale' -Passed (@($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_reason_codes) -contains 'sync_guard_stale') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_reason_codes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_alert_thresholds_critical_reason_slo_gate_runtime_error' -Passed (@($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_reason_codes) -contains 'slo_gate_runtime_error') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.slo_gate.alert_thresholds.critical_reason_codes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_error_budget_window_days' -Passed ([int]$releaseClient.ops_control_plane_policy.error_budget.window_days -eq 7) -Detail ([string]$releaseClient.ops_control_plane_policy.error_budget.window_days) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_error_budget_max_failed_runs' -Passed ([int]$releaseClient.ops_control_plane_policy.error_budget.max_failed_runs -eq 0) -Detail ([string]$releaseClient.ops_control_plane_policy.error_budget.max_failed_runs) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_error_budget_max_failure_rate_pct' -Passed ([double]$releaseClient.ops_control_plane_policy.error_budget.max_failure_rate_pct -eq 0) -Detail ([string]$releaseClient.ops_control_plane_policy.error_budget.max_failure_rate_pct) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_error_budget_critical_burn_rate_pct' -Passed ([double]$releaseClient.ops_control_plane_policy.error_budget.critical_burn_rate_pct -eq 100) -Detail ([string]$releaseClient.ops_control_plane_policy.error_budget.critical_burn_rate_pct) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_state_machine_version' -Passed ([string]$releaseClient.ops_control_plane_policy.state_machine.version -eq '1.0') -Detail ([string]$releaseClient.ops_control_plane_policy.state_machine.version) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_state_machine_initial_state' -Passed ([string]$releaseClient.ops_control_plane_policy.state_machine.initial_state -eq 'ops_health_preflight') -Detail ([string]$releaseClient.ops_control_plane_policy.state_machine.initial_state) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_state_machine_preflight_on_pass' -Passed ([string]$releaseClient.ops_control_plane_policy.state_machine.transitions.ops_health_preflight.on_pass -eq 'release_dispatch') -Detail ([string]$releaseClient.ops_control_plane_policy.state_machine.transitions.ops_health_preflight.on_pass) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_state_machine_preflight_on_fail' -Passed ([string]$releaseClient.ops_control_plane_policy.state_machine.transitions.ops_health_preflight.on_fail -eq 'auto_remediation') -Detail ([string]$releaseClient.ops_control_plane_policy.state_machine.transitions.ops_health_preflight.on_fail) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_rollback_orchestration_enabled' -Passed ([bool]$releaseClient.ops_control_plane_policy.rollback_orchestration.enabled) -Detail ([string]$releaseClient.ops_control_plane_policy.rollback_orchestration.enabled) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_rollback_orchestration_trigger_watch_timeout' -Passed (@($releaseClient.ops_control_plane_policy.rollback_orchestration.trigger_reason_codes) -contains 'release_dispatch_watch_timeout') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.rollback_orchestration.trigger_reason_codes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_rollback_orchestration_trigger_release_verification_failed' -Passed (@($releaseClient.ops_control_plane_policy.rollback_orchestration.trigger_reason_codes) -contains 'release_verification_failed') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.rollback_orchestration.trigger_reason_codes))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_decision_trail_schema_version' -Passed ([string]$releaseClient.ops_control_plane_policy.decision_trail.schema_version -eq '1.0') -Detail ([string]$releaseClient.ops_control_plane_policy.decision_trail.schema_version) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_decision_trail_artifact_name_prefix' -Passed ([string]$releaseClient.ops_control_plane_policy.decision_trail.artifact_name_prefix -eq 'release-control-plane-decision-trail') -Detail ([string]$releaseClient.ops_control_plane_policy.decision_trail.artifact_name_prefix) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_decision_trail_hash_algorithm' -Passed ([string]$releaseClient.ops_control_plane_policy.decision_trail.hash_algorithm -eq 'sha256') -Detail ([string]$releaseClient.ops_control_plane_policy.decision_trail.hash_algorithm) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_decision_trail_include_state_machine' -Passed ([bool]$releaseClient.ops_control_plane_policy.decision_trail.include_state_machine) -Detail ([string]$releaseClient.ops_control_plane_policy.decision_trail.include_state_machine) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_decision_trail_include_rollback_orchestration' -Passed ([bool]$releaseClient.ops_control_plane_policy.decision_trail.include_rollback_orchestration) -Detail ([string]$releaseClient.ops_control_plane_policy.decision_trail.include_rollback_orchestration) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_required_workflow_ops_monitoring' -Passed (@($releaseClient.ops_control_plane_policy.slo_gate.required_workflows) -contains 'ops-monitoring') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.slo_gate.required_workflows))) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_required_workflow_ops_autoremediate' -Passed (@($releaseClient.ops_control_plane_policy.slo_gate.required_workflows) -contains 'ops-autoremediate') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.slo_gate.required_workflows))) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_slo_required_workflow_release_control_plane' -Passed (@($releaseClient.ops_control_plane_policy.slo_gate.required_workflows) -contains 'release-control-plane') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.slo_gate.required_workflows))) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_incident_auto_close' -Passed ([bool]$releaseClient.ops_control_plane_policy.incident_lifecycle.auto_close_on_recovery) -Detail ([string]$releaseClient.ops_control_plane_policy.incident_lifecycle.auto_close_on_recovery) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_incident_reopen' -Passed ([bool]$releaseClient.ops_control_plane_policy.incident_lifecycle.reopen_on_regression) -Detail ([string]$releaseClient.ops_control_plane_policy.incident_lifecycle.reopen_on_regression) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_incident_title_release_guardrails' -Passed (@($releaseClient.ops_control_plane_policy.incident_lifecycle.titles) -contains 'Release Guardrails Auto-Remediation Alert') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.incident_lifecycle.titles))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_incident_title_workflow_bot_token_health' -Passed (@($releaseClient.ops_control_plane_policy.incident_lifecycle.titles) -contains 'Workflow Bot Token Health Alert') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.incident_lifecycle.titles))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_tag_strategy_mode' -Passed ([string]$releaseClient.ops_control_plane_policy.tag_strategy.mode -eq 'dual-mode-semver-preferred') -Detail ([string]$releaseClient.ops_control_plane_policy.tag_strategy.mode) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_tag_strategy_legacy_tag_family' -Passed ([string]$releaseClient.ops_control_plane_policy.tag_strategy.legacy_tag_family -eq 'legacy_date_window') -Detail ([string]$releaseClient.ops_control_plane_policy.tag_strategy.legacy_tag_family) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_tag_strategy_semver_only_enforce' -Passed (([DateTime]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -eq '2026-07-01T00:00:00Z') -Detail ([string]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_tag_strategy_matches_signature_grace_end' -Passed (([DateTime]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -eq ([DateTime]$releaseClient.signature_policy.grace_end_utc).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) -Detail ("semver_only_enforce_utc={0}; signature_grace_end_utc={1}" -f [string]$releaseClient.ops_control_plane_policy.tag_strategy.semver_only_enforce_utc, [string]$releaseClient.signature_policy.grace_end_utc) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_stable_window_weekday_monday' -Passed (@($releaseClient.ops_control_plane_policy.stable_promotion_window.full_cycle_allowed_utc_weekdays) -contains 'Monday') -Detail ([string]::Join(',', @($releaseClient.ops_control_plane_policy.stable_promotion_window.full_cycle_allowed_utc_weekdays))) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_stable_window_allow_override' -Passed ([bool]$releaseClient.ops_control_plane_policy.stable_promotion_window.allow_outside_window_with_override) -Detail ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.allow_outside_window_with_override) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_stable_window_reason_required' -Passed ([bool]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_required) -Detail ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_required) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_stable_window_reason_min_length' -Passed ([int]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_min_length -eq 12) -Detail ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_min_length) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_stable_window_reason_pattern_exists' -Passed (-not [string]::IsNullOrWhiteSpace([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_pattern)) -Detail ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_pattern) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_stable_window_reason_pattern_has_reference_group' -Passed ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_pattern -match '\?') -Detail ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_pattern) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_stable_window_reason_pattern_has_summary_group' -Passed ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_pattern -match '\?') -Detail ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_pattern) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_stable_window_reason_example' -Passed (-not [string]::IsNullOrWhiteSpace([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_example)) -Detail ([string]$releaseClient.ops_control_plane_policy.stable_promotion_window.override_reason_example) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_enabled' -Passed ([bool]$releaseClient.ops_control_plane_policy.self_healing.enabled) -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.enabled) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_max_attempts' -Passed ([int]$releaseClient.ops_control_plane_policy.self_healing.max_attempts -eq 1) -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.max_attempts) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_slo_workflow' -Passed ([string]$releaseClient.ops_control_plane_policy.self_healing.slo_gate.remediation_workflow -eq 'ops-autoremediate.yml') -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.slo_gate.remediation_workflow) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_slo_watch_timeout' -Passed ([int]$releaseClient.ops_control_plane_policy.self_healing.slo_gate.watch_timeout_minutes -eq 45) -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.slo_gate.watch_timeout_minutes) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_slo_verify' -Passed ([bool]$releaseClient.ops_control_plane_policy.self_healing.slo_gate.verify_after_remediation) -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.slo_gate.verify_after_remediation) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_guardrails_workflow' -Passed ([string]$releaseClient.ops_control_plane_policy.self_healing.guardrails.remediation_workflow -eq 'release-guardrails-autoremediate.yml') -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.guardrails.remediation_workflow) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_guardrails_race_drill_workflow' -Passed ([string]$releaseClient.ops_control_plane_policy.self_healing.guardrails.race_drill_workflow -eq 'release-race-hardening-drill.yml') -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.guardrails.race_drill_workflow) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_guardrails_watch_timeout' -Passed ([int]$releaseClient.ops_control_plane_policy.self_healing.guardrails.watch_timeout_minutes -eq 120) -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.guardrails.watch_timeout_minutes) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_guardrails_verify' -Passed ([bool]$releaseClient.ops_control_plane_policy.self_healing.guardrails.verify_after_remediation) -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.guardrails.verify_after_remediation) + Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_guardrails_race_gate_max_age_hours' -Passed ([int]$releaseClient.ops_control_plane_policy.self_healing.guardrails.race_gate_max_age_hours -eq 168) -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.guardrails.race_gate_max_age_hours) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_rollback_workflow' -Passed ([string]$releaseClient.ops_control_plane_policy.self_healing.rollback_drill.release_workflow -eq 'release-workspace-installer.yml') -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.rollback_drill.release_workflow) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_rollback_branch' -Passed ([string]$releaseClient.ops_control_plane_policy.self_healing.rollback_drill.release_branch -eq 'main') -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.rollback_drill.release_branch) Add-Check -Scope 'manifest' -Name 'release_client_ops_policy_self_healing_rollback_watch_timeout' -Passed ([int]$releaseClient.ops_control_plane_policy.self_healing.rollback_drill.watch_timeout_minutes -eq 120) -Detail ([string]$releaseClient.ops_control_plane_policy.self_healing.rollback_drill.watch_timeout_minutes) diff --git a/workspace-governance-payload/workspace-governance/workspace-governance.json b/workspace-governance-payload/workspace-governance/workspace-governance.json index 486f2af..e165af5 100644 --- a/workspace-governance-payload/workspace-governance/workspace-governance.json +++ b/workspace-governance-payload/workspace-governance/workspace-governance.json @@ -391,6 +391,31 @@ "channel": "canary", "required_history_count": 2, "release_limit": 100 + }, + "cli_dependency_gate": { + "enabled": true, + "hard_block_modes": [ + "PromotePrerelease", + "PromoteStable", + "FullCycle" + ], + "warn_only_modes": [ + "Validate", + "CanaryCycle" + ], + "sync_guard_repository": "LabVIEW-Community-CI-CD/labview-cdev-cli", + "sync_guard_workflow": "fork-upstream-sync-guard", + "sync_guard_branch": "main", + "sync_guard_max_age_hours": 12, + "fork_repository": "svelderrainruiz/labview-cdev-cli", + "required_assets": [ + "cdev-cli-win-x64.zip", + "cdev-cli-linux-x64.tar.gz" + ], + "runtime_publish_repository": "LabVIEW-Community-CI-CD/labview-cdev-cli", + "runtime_publish_workflow": "publish-cli-runtime-image", + "runtime_publish_branch": "main", + "runtime_attestation_artifact": "cli-dependency-attestation" } }, "cdev_cli_sync": { @@ -698,4 +723,3 @@ } ] } - diff --git a/workspace-governance.json b/workspace-governance.json index 486f2af..e165af5 100644 --- a/workspace-governance.json +++ b/workspace-governance.json @@ -391,6 +391,31 @@ "channel": "canary", "required_history_count": 2, "release_limit": 100 + }, + "cli_dependency_gate": { + "enabled": true, + "hard_block_modes": [ + "PromotePrerelease", + "PromoteStable", + "FullCycle" + ], + "warn_only_modes": [ + "Validate", + "CanaryCycle" + ], + "sync_guard_repository": "LabVIEW-Community-CI-CD/labview-cdev-cli", + "sync_guard_workflow": "fork-upstream-sync-guard", + "sync_guard_branch": "main", + "sync_guard_max_age_hours": 12, + "fork_repository": "svelderrainruiz/labview-cdev-cli", + "required_assets": [ + "cdev-cli-win-x64.zip", + "cdev-cli-linux-x64.tar.gz" + ], + "runtime_publish_repository": "LabVIEW-Community-CI-CD/labview-cdev-cli", + "runtime_publish_workflow": "publish-cli-runtime-image", + "runtime_publish_branch": "main", + "runtime_attestation_artifact": "cli-dependency-attestation" } }, "cdev_cli_sync": { @@ -698,4 +723,3 @@ } ] } -