From 915d61cbef295d7979ec007380f350a1a4b7e5f0 Mon Sep 17 00:00:00 2001 From: svelderrainruiz Date: Fri, 27 Feb 2026 12:26:58 -0800 Subject: [PATCH] Add CLI runtime attestation and ops program interface --- .../workflows/publish-cli-runtime-image.yml | 53 +++++ AGENTS.md | 8 + README.md | 15 ++ cli-contract.json | 8 + scripts/Invoke-CdevCli.ps1 | 61 +++++- scripts/Write-CliDependencyAttestation.ps1 | 178 +++++++++++++++ scripts/lib/OpsProgram.Commands.ps1 | 203 ++++++++++++++++++ tests/CdevCliContract.Tests.ps1 | 8 +- ...evCliRuntimeImagePublishContract.Tests.ps1 | 23 +- 9 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 scripts/Write-CliDependencyAttestation.ps1 create mode 100644 scripts/lib/OpsProgram.Commands.ps1 diff --git a/.github/workflows/publish-cli-runtime-image.yml b/.github/workflows/publish-cli-runtime-image.yml index 5573b7f..f36d8f2 100644 --- a/.github/workflows/publish-cli-runtime-image.yml +++ b/.github/workflows/publish-cli-runtime-image.yml @@ -19,6 +19,7 @@ on: paths: - tools/cli-runtime/Dockerfile - scripts/Invoke-CdevCli.ps1 + - scripts/Write-CliDependencyAttestation.ps1 - scripts/lib/** - cli-contract.json @@ -102,6 +103,57 @@ jobs: push: true tags: ${{ steps.resolve.outputs.tags }} + - name: Capture fork/upstream sync evidence + id: sync_guard + continue-on-error: true + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $syncReportPath = Join-Path $env:RUNNER_TEMP 'fork-upstream-sync-drift-report.json' + & pwsh -NoProfile -File ./scripts/Test-ForkUpstreamSyncGuard.ps1 ` + -UpstreamRepository 'LabVIEW-Community-CI-CD/labview-cdev-cli' ` + -ForkRepository 'svelderrainruiz/labview-cdev-cli' ` + -UpstreamBranch 'main' ` + -ForkBranch 'main' ` + -OutputPath $syncReportPath + + - name: Write CLI dependency attestation + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $syncReportPath = Join-Path $env:RUNNER_TEMP 'fork-upstream-sync-drift-report.json' + $attestationPath = Join-Path $env:RUNNER_TEMP 'cli-dependency-attestation.json' + + $syncReportArgs = @() + if (Test-Path -LiteralPath $syncReportPath -PathType Leaf) { + $syncReportArgs = @('-SyncGuardReportPath', $syncReportPath) + } + + & pwsh -NoProfile -File ./scripts/Write-CliDependencyAttestation.ps1 ` + -SourceCommit '${{ github.sha }}' ` + -RuntimeImageRepository '${{ steps.resolve.outputs.image_repo }}' ` + -RuntimeImageDigest '${{ steps.build.outputs.digest }}' ` + -OutputPath $attestationPath ` + @syncReportArgs + + - name: Upload sync-guard evidence report + if: always() + uses: actions/upload-artifact@v4 + with: + name: fork-upstream-sync-drift-report + path: ${{ runner.temp }}/fork-upstream-sync-drift-report.json + if-no-files-found: warn + + - name: Upload CLI dependency attestation + if: always() + uses: actions/upload-artifact@v4 + with: + name: cli-dependency-attestation + path: ${{ runner.temp }}/cli-dependency-attestation.json + if-no-files-found: error + - name: Publish summary shell: bash run: | @@ -111,6 +163,7 @@ jobs: echo "- Image: \`${{ steps.resolve.outputs.image_repo }}\`" echo "- Digest: \`${{ steps.build.outputs.digest }}\`" echo "- Commit: \`${GITHUB_SHA}\`" + echo "- Dependency attestation: \`cli-dependency-attestation.json\`" echo "- Tags:" while IFS= read -r tag; do echo " - \`$tag\`" diff --git a/AGENTS.md b/AGENTS.md index 1200fd0..724ac10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,12 +34,19 @@ This repository is the control-plane CLI for deterministic `C:\dev` workspace or - installer iterations (`installer exercise`) - post-action gate summaries (`postactions collect`) - Linux NI deploy checks (`linux deploy-ni`). + - release program orchestration (`ops program run|status|freeze|unfreeze|drill|evidence export`). - Core command tokens that must stay stable: - `Invoke-CdevCli.ps1` - `repos doctor` - `installer exercise` - `postactions collect` - `linux deploy-ni` + - `ops program run` + - `ops program status` + - `ops program freeze` + - `ops program unfreeze` + - `ops program drill` + - `ops program evidence export` - `desktop-linux` - `nationalinstruments/labview:latest-linux` @@ -83,3 +90,4 @@ This repository is the control-plane CLI for deterministic `C:\dev` workspace or - `cdev-cli.slsa.json` - `publish-cli-runtime-image.yml` publishes base runtime image `ghcr.io//labview-cdev-cli-runtime` with immutable tags (`sha-*`, `v1-YYYYMMDD`) and optional mutable `v1`. - Canonical consumer image path is `ghcr.io/labview-community-ci-cd/labview-cdev-cli-runtime`. +- `publish-cli-runtime-image.yml` must upload `cli-dependency-attestation.json` with runtime digest + sync-guard/parity evidence. diff --git a/README.md b/README.md index 3745e37..a2be53a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ On Linux, invoke the same entrypoint with `pwsh -NoProfile -File`. - `linux install` - `linux deploy-ni` - `ci integration-gate` +- `ops program run` +- `ops program status` +- `ops program freeze` +- `ops program unfreeze` +- `ops program drill` +- `ops program evidence export` - `release package` ## Quick Start @@ -32,6 +38,7 @@ powershell -NoProfile -ExecutionPolicy RemoteSigned -File .\scripts\Invoke-CdevC powershell -NoProfile -ExecutionPolicy RemoteSigned -File .\scripts\Invoke-CdevCli.ps1 repos doctor --workspace-root C:\dev powershell -NoProfile -ExecutionPolicy RemoteSigned -File .\scripts\Invoke-CdevCli.ps1 installer exercise --mode fast --iterations 1 powershell -NoProfile -ExecutionPolicy RemoteSigned -File .\scripts\Invoke-CdevCli.ps1 ci integration-gate --repo svelderrainruiz/labview-cdev-surface --branch main +powershell -NoProfile -ExecutionPolicy RemoteSigned -File .\scripts\Invoke-CdevCli.ps1 ops program run --mode Validate --dry-run true --enrollment-repo LabVIEW-Community-CI-CD/labview-cdev-surface-fork ``` ## Linux Flow (Docker Desktop Linux) @@ -67,6 +74,14 @@ Deterministic tags: - `v1-YYYYMMDD` - `v1` (when promoted) +The publish workflow also emits: +- `cli-dependency-attestation.json` + - `source_commit` + - `runtime_image.repository` + - `runtime_image.digest` + - `sync_guard_evidence` + - `parity_evidence` + Dispatch manually: ```powershell diff --git a/cli-contract.json b/cli-contract.json index e3c8c6c..ea281fb 100644 --- a/cli-contract.json +++ b/cli-contract.json @@ -9,6 +9,14 @@ "postactions": ["collect"], "linux": ["install", "deploy-ni"], "ci": ["integration-gate"], + "ops": [ + "program run", + "program status", + "program freeze", + "program unfreeze", + "program drill", + "program evidence export" + ], "release": ["package"] } } diff --git a/scripts/Invoke-CdevCli.ps1 b/scripts/Invoke-CdevCli.ps1 index 2544273..c7e6e21 100644 --- a/scripts/Invoke-CdevCli.ps1 +++ b/scripts/Invoke-CdevCli.ps1 @@ -32,6 +32,7 @@ foreach ($libFile in @( 'Installer.Commands.ps1', 'Linux.Commands.ps1', 'Ci.Commands.ps1', + 'OpsProgram.Commands.ps1', 'Release.Commands.ps1' )) { $path = Join-Path $libRoot $libFile @@ -62,6 +63,12 @@ function Show-CdevHelp { ' linux install', ' linux deploy-ni', ' ci integration-gate', + ' ops program run', + ' ops program status', + ' ops program freeze', + ' ops program unfreeze', + ' ops program drill', + ' ops program evidence export', ' release package', '', 'Examples:', @@ -70,7 +77,8 @@ function Show-CdevHelp { ' powershell -NoProfile -ExecutionPolicy RemoteSigned -File scripts/Invoke-CdevCli.ps1 installer exercise --mode fast --iterations 1', ' powershell -NoProfile -ExecutionPolicy RemoteSigned -File scripts/Invoke-CdevCli.ps1 postactions collect --report-path C:\dev\artifacts\workspace-install-latest.json', ' powershell -NoProfile -ExecutionPolicy RemoteSigned -File scripts/Invoke-CdevCli.ps1 linux install --workspace-root C:\dev-linux', - ' powershell -NoProfile -ExecutionPolicy RemoteSigned -File scripts/Invoke-CdevCli.ps1 linux deploy-ni --workspace-root C:\dev-linux --docker-context desktop-linux' + ' powershell -NoProfile -ExecutionPolicy RemoteSigned -File scripts/Invoke-CdevCli.ps1 linux deploy-ni --workspace-root C:\dev-linux --docker-context desktop-linux', + ' powershell -NoProfile -ExecutionPolicy RemoteSigned -File scripts/Invoke-CdevCli.ps1 ops program run --mode Validate --dry-run true --enrollment-repo LabVIEW-Community-CI-CD/labview-cdev-surface-fork' ) if ([string]::IsNullOrWhiteSpace($Topic)) { @@ -84,6 +92,7 @@ function Show-CdevHelp { 'postactions' { Write-Host 'postactions command: collect' } 'linux' { Write-Host 'linux commands: install, deploy-ni' } 'ci' { Write-Host 'ci command: integration-gate' } + 'ops' { Write-Host 'ops commands: program run|status|freeze|unfreeze|drill|evidence export' } 'release' { Write-Host 'release command: package' } default { Write-Host "Unknown help topic '$Topic'." @@ -205,6 +214,56 @@ try { } } } + 'ops' { + switch ($command) { + 'program' { + if ($passThroughArgs.Count -lt 1) { + throw "Unsupported ops program command ''. Use 'ops program run|status|freeze|unfreeze|drill|evidence export'." + } + + $programAction = ([string]$passThroughArgs[0]).ToLowerInvariant() + $programArgs = if ($passThroughArgs.Count -gt 1) { @($passThroughArgs[1..($passThroughArgs.Count - 1)]) } else { @() } + switch ($programAction) { + 'run' { + $result = Invoke-CdevOpsProgramRun -PassThroughArgs $programArgs + } + 'status' { + $result = Invoke-CdevOpsProgramStatus -PassThroughArgs $programArgs + } + 'freeze' { + $result = Invoke-CdevOpsProgramFreeze -PassThroughArgs $programArgs + } + 'unfreeze' { + $result = Invoke-CdevOpsProgramUnfreeze -PassThroughArgs $programArgs + } + 'drill' { + $result = Invoke-CdevOpsProgramDrill -PassThroughArgs $programArgs + } + 'evidence' { + if ($programArgs.Count -lt 1) { + throw "Unsupported ops program evidence subcommand ''. Use 'ops program evidence export'." + } + $evidenceAction = ([string]$programArgs[0]).ToLowerInvariant() + $evidenceArgs = if ($programArgs.Count -gt 1) { @($programArgs[1..($programArgs.Count - 1)]) } else { @() } + switch ($evidenceAction) { + 'export' { + $result = Invoke-CdevOpsProgramEvidenceExport -PassThroughArgs $evidenceArgs + } + default { + throw "Unsupported ops program evidence subcommand '$evidenceAction'. Use 'ops program evidence export'." + } + } + } + default { + throw "Unsupported ops program command '$programAction'. Use 'ops program run|status|freeze|unfreeze|drill|evidence export'." + } + } + } + default { + throw "Unsupported ops command '$command'. Use 'ops program ...'." + } + } + } 'release' { switch ($command) { 'package' { diff --git a/scripts/Write-CliDependencyAttestation.ps1 b/scripts/Write-CliDependencyAttestation.ps1 new file mode 100644 index 0000000..dbe9aa5 --- /dev/null +++ b/scripts/Write-CliDependencyAttestation.ps1 @@ -0,0 +1,178 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter()] + [string]$SourceCommit = '', + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$RuntimeImageRepository, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^sha256:[0-9a-f]{64}$')] + [string]$RuntimeImageDigest, + + [Parameter()] + [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] + [string]$UpstreamRepository = '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]$UpstreamBranch = 'main', + + [Parameter()] + [ValidatePattern('^[A-Za-z0-9._/-]+$')] + [string]$ForkBranch = 'main', + + [Parameter()] + [string[]]$RequiredAssets = @( + 'cdev-cli-win-x64.zip', + 'cdev-cli-linux-x64.tar.gz' + ), + + [Parameter()] + [string]$SyncGuardReportPath = '', + + [Parameter()] + [string]$OutputPath = 'cli-dependency-attestation.json' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-PropertyValueOrDefault { + param( + [Parameter()][object]$Object, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter()][object]$DefaultValue = $null + ) + + if ($null -eq $Object) { + return $DefaultValue + } + + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { + return $DefaultValue + } + + return $property.Value +} + +if ([string]::IsNullOrWhiteSpace([string]$SourceCommit)) { + $SourceCommit = [string]$env:GITHUB_SHA +} +if ([string]::IsNullOrWhiteSpace([string]$SourceCommit)) { + $SourceCommit = 'unknown' +} + +$syncGuardStatus = 'unavailable' +$syncGuardEvidence = [ordered]@{ + status = 'unavailable' + reason = 'sync_guard_report_missing' + report_path = '' + upstream_repository = $UpstreamRepository + fork_repository = $ForkRepository + branch_parity = $null + release_parity = $null + asset_parity = @() + mismatches = @() +} + +if (-not [string]::IsNullOrWhiteSpace($SyncGuardReportPath) -and (Test-Path -LiteralPath $SyncGuardReportPath -PathType Leaf)) { + try { + $syncGuardReport = Get-Content -LiteralPath $SyncGuardReportPath -Raw | ConvertFrom-Json -Depth 20 + $syncGuardStatus = [string](Get-PropertyValueOrDefault -Object $syncGuardReport -Name 'status' -DefaultValue 'drift_detected') + if ([string]::IsNullOrWhiteSpace($syncGuardStatus)) { + $syncGuardStatus = 'drift_detected' + } + + $syncGuardEvidence = [ordered]@{ + status = $syncGuardStatus + reason = if ($syncGuardStatus -eq 'in_sync') { 'ok' } else { 'sync_drift_detected' } + report_path = [System.IO.Path]::GetFullPath($SyncGuardReportPath) + generated_at_utc = [string](Get-PropertyValueOrDefault -Object $syncGuardReport -Name 'generated_at_utc' -DefaultValue '') + upstream_repository = [string](Get-PropertyValueOrDefault -Object $syncGuardReport -Name 'upstream_repository' -DefaultValue $UpstreamRepository) + fork_repository = [string](Get-PropertyValueOrDefault -Object $syncGuardReport -Name 'fork_repository' -DefaultValue $ForkRepository) + branch_parity = Get-PropertyValueOrDefault -Object $syncGuardReport -Name 'branch_parity' -DefaultValue $null + release_parity = Get-PropertyValueOrDefault -Object $syncGuardReport -Name 'release_parity' -DefaultValue $null + asset_parity = @( + @(Get-PropertyValueOrDefault -Object $syncGuardReport -Name 'asset_parity' -DefaultValue @()) | + ForEach-Object { + [ordered]@{ + asset = [string](Get-PropertyValueOrDefault -Object $_ -Name 'asset' -DefaultValue '') + upstream_digest = [string](Get-PropertyValueOrDefault -Object $_ -Name 'upstream_digest' -DefaultValue '') + fork_digest = [string](Get-PropertyValueOrDefault -Object $_ -Name 'fork_digest' -DefaultValue '') + matches = [bool](Get-PropertyValueOrDefault -Object $_ -Name 'matches' -DefaultValue $false) + } + } + ) + mismatches = @( + @(Get-PropertyValueOrDefault -Object $syncGuardReport -Name 'mismatches' -DefaultValue @()) | + ForEach-Object { [string]$_ } + ) + } + } catch { + $syncGuardStatus = 'unavailable' + $syncGuardEvidence = [ordered]@{ + status = 'unavailable' + reason = 'sync_guard_report_parse_failed' + message = [string]$_.Exception.Message + report_path = [System.IO.Path]::GetFullPath($SyncGuardReportPath) + upstream_repository = $UpstreamRepository + fork_repository = $ForkRepository + branch_parity = $null + release_parity = $null + asset_parity = @() + mismatches = @() + } + } +} + +$requiredAssetSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) +foreach ($assetName in @($RequiredAssets)) { + $normalizedAsset = ([string]$assetName).Trim() + if (-not [string]::IsNullOrWhiteSpace($normalizedAsset)) { + [void]$requiredAssetSet.Add($normalizedAsset) + } +} + +$assetParityRecords = @( + @($syncGuardEvidence.asset_parity) | + Where-Object { $requiredAssetSet.Contains([string]$_.asset) } +) +$assetParityComplete = ($requiredAssetSet.Count -gt 0) -and (@($assetParityRecords).Count -eq $requiredAssetSet.Count) +$assetParityMatches = $assetParityComplete -and (@($assetParityRecords | Where-Object { -not [bool]$_.matches }).Count -eq 0) +$branchParityMatches = [bool](Get-PropertyValueOrDefault -Object $syncGuardEvidence.branch_parity -Name 'matches' -DefaultValue $false) +$releaseParityMatches = [bool](Get-PropertyValueOrDefault -Object $syncGuardEvidence.release_parity -Name 'matches' -DefaultValue $false) + +$attestation = [ordered]@{ + schema_version = '1.0' + generated_at_utc = (Get-Date).ToUniversalTime().ToString('o') + source_commit = $SourceCommit + canonical_runtime_repository = 'ghcr.io/labview-community-ci-cd/labview-cdev-cli-runtime' + runtime_image = [ordered]@{ + repository = $RuntimeImageRepository + digest = $RuntimeImageDigest + } + sync_guard_evidence = $syncGuardEvidence + parity_evidence = [ordered]@{ + branch_head_match = $branchParityMatches + latest_release_tag_match = $releaseParityMatches + required_asset_digest_match = $assetParityMatches + required_assets = @($requiredAssetSet | Sort-Object) + } + status = if ($branchParityMatches -and $releaseParityMatches -and $assetParityMatches) { 'in_sync' } else { 'drift_detected' } +} + +$outputDirectory = Split-Path -Path $OutputPath -Parent +if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) { + New-Item -Path $outputDirectory -ItemType Directory -Force | Out-Null +} + +$attestation | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM +Write-Host "CLI dependency attestation written: $OutputPath" diff --git a/scripts/lib/OpsProgram.Commands.ps1 b/scripts/lib/OpsProgram.Commands.ps1 new file mode 100644 index 0000000..b0d2638 --- /dev/null +++ b/scripts/lib/OpsProgram.Commands.ps1 @@ -0,0 +1,203 @@ +function Convert-CdevBooleanToString { + param([Parameter(Mandatory = $true)][bool]$Value) + if ($Value) { + return 'true' + } + return 'false' +} + +function Resolve-CdevOpsProgramRunId { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][string]$Workflow + ) + + $runListJson = & gh run list -R $Repository --workflow $Workflow --limit 1 --json databaseId,status,conclusion,url,createdAt + if ($LASTEXITCODE -ne 0) { + throw "Failed to resolve latest run for workflow '$Workflow' in '$Repository'." + } + + $runs = @($runListJson | ConvertFrom-Json -ErrorAction Stop) + if (@($runs).Count -eq 0) { + throw "No runs found for workflow '$Workflow' in '$Repository'." + } + + return [string]$runs[0].databaseId +} + +function Invoke-CdevOpsProgramRun { + param([string[]]$PassThroughArgs) + + Assert-CdevCommand -Name 'gh' + $argsMap = Convert-CdevArgsToMap -InputArgs $PassThroughArgs + + $repository = if ($argsMap.ContainsKey('repo')) { [string]$argsMap['repo'] } else { 'LabVIEW-Community-CI-CD/labview-release-control-plane' } + $branch = if ($argsMap.ContainsKey('branch')) { [string]$argsMap['branch'] } else { 'main' } + $workflow = if ($argsMap.ContainsKey('workflow')) { [string]$argsMap['workflow'] } else { 'release-program.yml' } + $mode = if ($argsMap.ContainsKey('mode')) { [string]$argsMap['mode'] } else { 'Validate' } + $dryRun = if ($argsMap.ContainsKey('dry-run')) { [System.Convert]::ToBoolean($argsMap['dry-run']) } else { $true } + $enrollmentRepo = if ($argsMap.ContainsKey('enrollment-repo')) { [string]$argsMap['enrollment-repo'] } else { 'LabVIEW-Community-CI-CD/labview-cdev-surface-fork' } + $policyPath = if ($argsMap.ContainsKey('policy-path')) { [string]$argsMap['policy-path'] } else { 'contracts/platform-policy.json' } + + $dispatchArgs = @('workflow', 'run', $workflow, '-R', $repository, '--ref', $branch) + if (-not [string]::IsNullOrWhiteSpace($mode)) { + $dispatchArgs += @('-f', "mode=$mode") + } + $dispatchArgs += @('-f', "dry_run=$(Convert-CdevBooleanToString -Value $dryRun)") + if (-not [string]::IsNullOrWhiteSpace($enrollmentRepo)) { + $dispatchArgs += @('-f', "enrollment_repo=$enrollmentRepo") + } + if (-not [string]::IsNullOrWhiteSpace($policyPath)) { + $dispatchArgs += @('-f', "policy_path=$policyPath") + } + + & gh @dispatchArgs + if ($LASTEXITCODE -ne 0) { + throw "Failed to dispatch release program workflow '$workflow' on '$repository' ref '$branch'." + } + + return (New-CdevResult -Status 'succeeded' -Data ([ordered]@{ + repository = $repository + workflow = $workflow + branch = $branch + mode = $mode + dry_run = $dryRun + enrollment_repo = $enrollmentRepo + policy_path = $policyPath + status = 'dispatched' + })) +} + +function Invoke-CdevOpsProgramStatus { + param([string[]]$PassThroughArgs) + + Assert-CdevCommand -Name 'gh' + $argsMap = Convert-CdevArgsToMap -InputArgs $PassThroughArgs + + $repository = if ($argsMap.ContainsKey('repo')) { [string]$argsMap['repo'] } else { 'LabVIEW-Community-CI-CD/labview-release-control-plane' } + $workflow = if ($argsMap.ContainsKey('workflow')) { [string]$argsMap['workflow'] } else { 'release-program.yml' } + $limit = if ($argsMap.ContainsKey('limit')) { [int]$argsMap['limit'] } else { 5 } + + $runListJson = & gh run list -R $repository --workflow $workflow --limit $limit --json databaseId,status,conclusion,url,headSha,event,createdAt,updatedAt + if ($LASTEXITCODE -ne 0) { + throw "Failed to list release program runs for '$repository' workflow '$workflow'." + } + + $runs = @($runListJson | ConvertFrom-Json -ErrorAction Stop) + return (New-CdevResult -Status 'succeeded' -Data ([ordered]@{ + repository = $repository + workflow = $workflow + run_count = @($runs).Count + runs = @($runs) + })) +} + +function Invoke-CdevOpsProgramFreeze { + param([string[]]$PassThroughArgs) + + Assert-CdevCommand -Name 'gh' + $argsMap = Convert-CdevArgsToMap -InputArgs $PassThroughArgs + + $repository = if ($argsMap.ContainsKey('repo')) { [string]$argsMap['repo'] } else { 'LabVIEW-Community-CI-CD/labview-release-control-plane' } + $branch = if ($argsMap.ContainsKey('branch')) { [string]$argsMap['branch'] } else { 'main' } + $workflow = if ($argsMap.ContainsKey('workflow')) { [string]$argsMap['workflow'] } else { 'release-program.yml' } + $reason = if ($argsMap.ContainsKey('reason')) { [string]$argsMap['reason'] } else { 'manual_freeze' } + + & gh workflow run $workflow -R $repository --ref $branch -f mode=Freeze -f "freeze_reason=$reason" + if ($LASTEXITCODE -ne 0) { + throw "Failed to dispatch freeze operation in '$repository' workflow '$workflow'." + } + + return (New-CdevResult -Status 'succeeded' -Data ([ordered]@{ + repository = $repository + workflow = $workflow + branch = $branch + operation = 'freeze' + reason = $reason + status = 'dispatched' + })) +} + +function Invoke-CdevOpsProgramUnfreeze { + param([string[]]$PassThroughArgs) + + Assert-CdevCommand -Name 'gh' + $argsMap = Convert-CdevArgsToMap -InputArgs $PassThroughArgs + + $repository = if ($argsMap.ContainsKey('repo')) { [string]$argsMap['repo'] } else { 'LabVIEW-Community-CI-CD/labview-release-control-plane' } + $branch = if ($argsMap.ContainsKey('branch')) { [string]$argsMap['branch'] } else { 'main' } + $workflow = if ($argsMap.ContainsKey('workflow')) { [string]$argsMap['workflow'] } else { 'release-program.yml' } + $reason = if ($argsMap.ContainsKey('reason')) { [string]$argsMap['reason'] } else { 'manual_unfreeze' } + + & gh workflow run $workflow -R $repository --ref $branch -f mode=Unfreeze -f "unfreeze_reason=$reason" + if ($LASTEXITCODE -ne 0) { + throw "Failed to dispatch unfreeze operation in '$repository' workflow '$workflow'." + } + + return (New-CdevResult -Status 'succeeded' -Data ([ordered]@{ + repository = $repository + workflow = $workflow + branch = $branch + operation = 'unfreeze' + reason = $reason + status = 'dispatched' + })) +} + +function Invoke-CdevOpsProgramDrill { + param([string[]]$PassThroughArgs) + + Assert-CdevCommand -Name 'gh' + $argsMap = Convert-CdevArgsToMap -InputArgs $PassThroughArgs + + $repository = if ($argsMap.ContainsKey('repo')) { [string]$argsMap['repo'] } else { 'LabVIEW-Community-CI-CD/labview-release-control-plane' } + $branch = if ($argsMap.ContainsKey('branch')) { [string]$argsMap['branch'] } else { 'main' } + $workflow = if ($argsMap.ContainsKey('workflow')) { [string]$argsMap['workflow'] } else { 'release-program.yml' } + $drillType = if ($argsMap.ContainsKey('drill-type')) { [string]$argsMap['drill-type'] } else { 'recovery' } + $dryRun = if ($argsMap.ContainsKey('dry-run')) { [System.Convert]::ToBoolean($argsMap['dry-run']) } else { $false } + + & gh workflow run $workflow -R $repository --ref $branch -f mode=Drill -f "drill_type=$drillType" -f "dry_run=$(Convert-CdevBooleanToString -Value $dryRun)" + if ($LASTEXITCODE -ne 0) { + throw "Failed to dispatch drill operation in '$repository' workflow '$workflow'." + } + + return (New-CdevResult -Status 'succeeded' -Data ([ordered]@{ + repository = $repository + workflow = $workflow + branch = $branch + operation = 'drill' + drill_type = $drillType + dry_run = $dryRun + status = 'dispatched' + })) +} + +function Invoke-CdevOpsProgramEvidenceExport { + param([string[]]$PassThroughArgs) + + Assert-CdevCommand -Name 'gh' + $argsMap = Convert-CdevArgsToMap -InputArgs $PassThroughArgs + + $repository = if ($argsMap.ContainsKey('repo')) { [string]$argsMap['repo'] } else { 'LabVIEW-Community-CI-CD/labview-release-control-plane' } + $workflow = if ($argsMap.ContainsKey('workflow')) { [string]$argsMap['workflow'] } else { 'release-program.yml' } + $runId = if ($argsMap.ContainsKey('run-id')) { [string]$argsMap['run-id'] } else { '' } + $outputRoot = if ($argsMap.ContainsKey('output-root')) { [string]$argsMap['output-root'] } else { Join-Path (Get-Location).Path 'artifacts\ops-evidence' } + $resolvedOutputRoot = [System.IO.Path]::GetFullPath($outputRoot) + Ensure-CdevDirectory -Path $resolvedOutputRoot + + if ([string]::IsNullOrWhiteSpace($runId)) { + $runId = Resolve-CdevOpsProgramRunId -Repository $repository -Workflow $workflow + } + + & gh run download $runId -R $repository --dir $resolvedOutputRoot + if ($LASTEXITCODE -ne 0) { + throw "Failed to download run artifacts for run '$runId' in '$repository'." + } + + return (New-CdevResult -Status 'succeeded' -Data ([ordered]@{ + repository = $repository + workflow = $workflow + run_id = $runId + output_root = $resolvedOutputRoot + })) +} diff --git a/tests/CdevCliContract.Tests.ps1 b/tests/CdevCliContract.Tests.ps1 index cf3c662..9a721bd 100644 --- a/tests/CdevCliContract.Tests.ps1 +++ b/tests/CdevCliContract.Tests.ps1 @@ -51,24 +51,28 @@ Describe 'cdev CLI command contract' { $script:contract.commands.PSObject.Properties.Name | Should -Contain 'postactions' $script:contract.commands.PSObject.Properties.Name | Should -Contain 'linux' $script:contract.commands.PSObject.Properties.Name | Should -Contain 'ci' + $script:contract.commands.PSObject.Properties.Name | Should -Contain 'ops' $script:contract.commands.PSObject.Properties.Name | Should -Contain 'release' } It 'implements required command tokens in entrypoint' { foreach ($token in @( 'repos', 'doctor', 'surface', 'sync', 'installer', 'build', 'exercise', 'install', - 'postactions', 'collect', 'linux', 'deploy-ni', 'integration-gate', 'release', 'package' + 'postactions', 'collect', 'linux', 'deploy-ni', 'integration-gate', + 'ops', 'program', 'freeze', 'unfreeze', 'drill', 'evidence', 'export', + 'release', 'package' )) { $script:content | Should -Match ([regex]::Escape($token)) } } It 'documents CLI orchestration in AGENTS and README' { - foreach ($token in @('Invoke-CdevCli.ps1', 'repos doctor', 'installer exercise', 'postactions collect', 'linux deploy-ni', 'desktop-linux', 'nationalinstruments/labview:latest-linux')) { + foreach ($token in @('Invoke-CdevCli.ps1', 'repos doctor', 'installer exercise', 'postactions collect', 'linux deploy-ni', 'ops program run', 'ops program status', 'desktop-linux', 'nationalinstruments/labview:latest-linux')) { $script:agents | Should -Match ([regex]::Escape($token)) } $script:readme | Should -Match ([regex]::Escape('Invoke-CdevCli.ps1')) $script:readme | Should -Match ([regex]::Escape('linux deploy-ni')) + $script:readme | Should -Match ([regex]::Escape('ops program run')) } It 'runs help command without requiring a surface root path' { diff --git a/tests/CdevCliRuntimeImagePublishContract.Tests.ps1 b/tests/CdevCliRuntimeImagePublishContract.Tests.ps1 index d31acbd..464411a 100644 --- a/tests/CdevCliRuntimeImagePublishContract.Tests.ps1 +++ b/tests/CdevCliRuntimeImagePublishContract.Tests.ps1 @@ -8,9 +8,10 @@ Describe 'cdev CLI runtime image publish contract' { $script:repoRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..')).Path $script:dockerfilePath = Join-Path $script:repoRoot 'tools/cli-runtime/Dockerfile' $script:workflowPath = Join-Path $script:repoRoot '.github/workflows/publish-cli-runtime-image.yml' + $script:attestationScriptPath = Join-Path $script:repoRoot 'scripts/Write-CliDependencyAttestation.ps1' $script:agentsPath = Join-Path $script:repoRoot 'AGENTS.md' - foreach ($path in @($script:dockerfilePath, $script:workflowPath, $script:agentsPath)) { + foreach ($path in @($script:dockerfilePath, $script:workflowPath, $script:attestationScriptPath, $script:agentsPath)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing runtime-image contract file: $path" } @@ -18,6 +19,7 @@ Describe 'cdev CLI runtime image publish contract' { $script:dockerfile = Get-Content -LiteralPath $script:dockerfilePath -Raw $script:workflow = Get-Content -LiteralPath $script:workflowPath -Raw + $script:attestationScript = Get-Content -LiteralPath $script:attestationScriptPath -Raw $script:agents = Get-Content -LiteralPath $script:agentsPath -Raw } @@ -44,9 +46,28 @@ Describe 'cdev CLI runtime image publish contract' { $script:workflow | Should -Match 'steps\.build\.outputs\.digest' } + It 'emits CLI dependency attestation with sync-guard parity evidence' { + $script:workflow | Should -Match 'Capture fork/upstream sync evidence' + $script:workflow | Should -Match 'Test-ForkUpstreamSyncGuard\.ps1' + $script:workflow | Should -Match 'Write-CliDependencyAttestation\.ps1' + $script:workflow | Should -Match 'Upload CLI dependency attestation' + $script:workflow | Should -Match 'cli-dependency-attestation\.json' + $script:workflow | Should -Match 'Upload sync-guard evidence report' + $script:workflow | Should -Match 'fork-upstream-sync-drift-report\.json' + + $script:attestationScript | Should -Match 'schema_version' + $script:attestationScript | Should -Match 'source_commit' + $script:attestationScript | Should -Match 'runtime_image' + $script:attestationScript | Should -Match 'canonical_runtime_repository' + $script:attestationScript | Should -Match 'sync_guard_evidence' + $script:attestationScript | Should -Match 'parity_evidence' + $script:attestationScript | Should -Match 'required_asset_digest_match' + } + It 'documents fork-safe mutation target for runtime publish operations' { $script:agents | Should -Match 'Allowed mutation target' $script:agents | Should -Match 'svelderrainruiz/labview-cdev-cli' $script:agents | Should -Match 'ghcr\.io/labview-community-ci-cd/labview-cdev-cli-runtime' + $script:agents | Should -Match 'cli-dependency-attestation\.json' } }