diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f404142..9f86d4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,8 @@ jobs: './tests/CdevCliContract.Tests.ps1', './tests/CdevCliInstallerContract.Tests.ps1', './tests/CdevCliLinuxContract.Tests.ps1', - './tests/CdevCliCiContract.Tests.ps1' + './tests/CdevCliCiContract.Tests.ps1', + './tests/CdevCliSyncGuardContract.Tests.ps1' ) -CI -Output Detailed cli-contract: diff --git a/.github/workflows/fork-upstream-sync-guard.yml b/.github/workflows/fork-upstream-sync-guard.yml new file mode 100644 index 0000000..a5e4e89 --- /dev/null +++ b/.github/workflows/fork-upstream-sync-guard.yml @@ -0,0 +1,40 @@ +name: fork-upstream-sync-guard + +on: + workflow_dispatch: + schedule: + - cron: '17 */6 * * *' + +permissions: + contents: read + +jobs: + sync-guard: + name: Fork/Upstream Sync Guard + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run parity checks and emit drift report + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $reportPath = 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 $reportPath + + - name: Upload drift 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: error diff --git a/scripts/Test-ForkUpstreamSyncGuard.ps1 b/scripts/Test-ForkUpstreamSyncGuard.ps1 new file mode 100644 index 0000000..c21fd24 --- /dev/null +++ b/scripts/Test-ForkUpstreamSyncGuard.ps1 @@ -0,0 +1,149 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [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()] + [ValidateNotNullOrEmpty()] + [string]$OutputPath = (Join-Path (Get-Location) 'fork-upstream-sync-drift-report.json') +) + +$ErrorActionPreference = 'Stop' + +function Invoke-GhQuery { + param( + [Parameter(Mandatory)] + [string[]]$Arguments, + + [Parameter(Mandatory)] + [string]$Description + ) + + $result = & gh @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Failed to query $Description." + } + return [string]$result +} + +function Get-ReleaseAssetDigestMap { + param( + [Parameter(Mandatory)] + [string]$Repository, + + [Parameter(Mandatory)] + [string]$Tag + ) + + $json = & gh release view $Tag -R $Repository --json assets + if ($LASTEXITCODE -ne 0) { + throw "Failed to query release '$Tag' in '$Repository'." + } + + $release = $json | ConvertFrom-Json -ErrorAction Stop + $map = @{} + foreach ($asset in @($release.assets)) { + $name = [string]$asset.name + if (-not [string]::IsNullOrWhiteSpace($name)) { + $map[$name] = [string]$asset.digest + } + } + return $map +} + +if (-not (Get-Command -Name 'gh' -ErrorAction SilentlyContinue)) { + throw 'GitHub CLI (gh) is required for sync guard checks.' +} + +$upstreamHead = (Invoke-GhQuery -Arguments @('api', "repos/$UpstreamRepository/commits/$UpstreamBranch", '--jq', '.sha') -Description "upstream branch head").Trim() +$forkHead = (Invoke-GhQuery -Arguments @('api', "repos/$ForkRepository/commits/$ForkBranch", '--jq', '.sha') -Description "fork branch head").Trim() +$upstreamLatestTag = (Invoke-GhQuery -Arguments @('api', "repos/$UpstreamRepository/releases/latest", '--jq', '.tag_name') -Description "upstream latest release tag").Trim() +$forkLatestTag = (Invoke-GhQuery -Arguments @('api', "repos/$ForkRepository/releases/latest", '--jq', '.tag_name') -Description "fork latest release tag").Trim() + +$upstreamAssetDigests = Get-ReleaseAssetDigestMap -Repository $UpstreamRepository -Tag $upstreamLatestTag +$forkAssetDigests = Get-ReleaseAssetDigestMap -Repository $ForkRepository -Tag $forkLatestTag + +$requiredAssets = @( + 'cdev-cli-win-x64.zip', + 'cdev-cli-linux-x64.tar.gz' +) + +$mismatches = New-Object System.Collections.Generic.List[string] +$branchMatches = [string]::Equals($upstreamHead, $forkHead, [System.StringComparison]::Ordinal) +if (-not $branchMatches) { + [void]$mismatches.Add('main_head') +} + +$releaseMatches = [string]::Equals($upstreamLatestTag, $forkLatestTag, [System.StringComparison]::Ordinal) +if (-not $releaseMatches) { + [void]$mismatches.Add('latest_release_tag') +} + +$assetParity = New-Object System.Collections.Generic.List[object] +foreach ($assetName in $requiredAssets) { + $upstreamDigest = [string]$upstreamAssetDigests[$assetName] + $forkDigest = [string]$forkAssetDigests[$assetName] + $assetMatches = (-not [string]::IsNullOrWhiteSpace($upstreamDigest)) -and + (-not [string]::IsNullOrWhiteSpace($forkDigest)) -and + [string]::Equals($upstreamDigest, $forkDigest, [System.StringComparison]::Ordinal) + + if (-not $assetMatches) { + [void]$mismatches.Add("asset_digest:$assetName") + } + + $assetParity.Add([ordered]@{ + asset = $assetName + upstream_digest = $upstreamDigest + fork_digest = $forkDigest + matches = $assetMatches + }) +} + +$report = [ordered]@{ + schema_version = '1.0' + generated_at_utc = [DateTime]::UtcNow.ToString('o') + upstream_repository = $UpstreamRepository + fork_repository = $ForkRepository + branch_parity = [ordered]@{ + upstream_branch = $UpstreamBranch + fork_branch = $ForkBranch + upstream_head = $upstreamHead + fork_head = $forkHead + matches = $branchMatches + } + release_parity = [ordered]@{ + upstream_latest_tag = $upstreamLatestTag + fork_latest_tag = $forkLatestTag + matches = $releaseMatches + } + asset_parity = @($assetParity) + status = if ($mismatches.Count -eq 0) { 'in_sync' } else { 'drift_detected' } + mismatches = @($mismatches) +} + +$outputDirectory = Split-Path -Path $OutputPath -Parent +if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) { + New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null +} + +$report | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM +Write-Host "Sync guard report written: $OutputPath" + +if ($mismatches.Count -gt 0) { + throw "Drift detected: $($mismatches -join ', ')" +} diff --git a/tests/CdevCliSyncGuardContract.Tests.ps1 b/tests/CdevCliSyncGuardContract.Tests.ps1 new file mode 100644 index 0000000..d2e35a4 --- /dev/null +++ b/tests/CdevCliSyncGuardContract.Tests.ps1 @@ -0,0 +1,53 @@ +#Requires -Version 7.0 +#Requires -Modules Pester + +$ErrorActionPreference = 'Stop' + +Describe 'cdev CLI fork/upstream sync guard contract' { + BeforeAll { + $script:repoRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..')).Path + $script:workflowPath = Join-Path $script:repoRoot '.github/workflows/fork-upstream-sync-guard.yml' + $script:guardScriptPath = Join-Path $script:repoRoot 'scripts/Test-ForkUpstreamSyncGuard.ps1' + + foreach ($path in @($script:workflowPath, $script:guardScriptPath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Missing required sync guard contract file: $path" + } + } + + $script:workflow = Get-Content -LiteralPath $script:workflowPath -Raw + $script:guardScript = Get-Content -LiteralPath $script:guardScriptPath -Raw + } + + It 'runs on schedule and workflow dispatch' { + $script:workflow | Should -Match 'workflow_dispatch:' + $script:workflow | Should -Match 'schedule:' + $script:workflow | Should -Match 'cron:' + } + + It 'emits and uploads machine-readable drift report artifacts' { + $script:workflow | Should -Match 'fork-upstream-sync-drift-report\.json' + $script:workflow | Should -Match 'actions/upload-artifact@v4' + $script:workflow | Should -Match 'if:\s+always\(\)' + } + + It 'checks branch parity, release tag parity, and release asset digest parity' { + foreach ($token in @( + 'main_head', + 'latest_release_tag', + 'asset_digest:', + 'cdev-cli-win-x64.zip', + 'cdev-cli-linux-x64.tar.gz', + 'drift_detected' + )) { + $script:guardScript | Should -Match ([regex]::Escape($token)) + } + } + + It 'has parse-safe PowerShell syntax' { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseInput($script:guardScript, [ref]$tokens, [ref]$errors) + @($errors).Count | Should -Be 0 + } +}