diff --git a/.github/workflows/signed-copy-guard.yml b/.github/workflows/signed-copy-guard.yml new file mode 100644 index 0000000..e920ce6 --- /dev/null +++ b/.github/workflows/signed-copy-guard.yml @@ -0,0 +1,158 @@ +name: Signed copy guard + +# Check 2 of 2 in the signed-copy drift design (see +# src/docs/development.md#signed-copy-drift-guard). +# +# Fails a PR if it edits a file under the top-level signed-copy roots +# (Workloads/, windows-dev-config/, wsl-comfort/) that no longer matches +# its src/ counterpart (modulo the Authenticode signature block on .ps1 +# files). The shared comparator is src/tools/check-signed-drift.ps1. +# +# This workflow only runs when files under one of the three signed-copy +# roots are touched by the PR; PRs that edit only src/ skip it entirely. +# The sign pipeline (.pipelines/OneBranch.SignAndPackage.yml) remains the +# only thing that should ever write to those top-level paths. + +on: + pull_request: + branches: + - main + - signed-copy-drift-guard + paths: + - 'Workloads/**' + - 'windows-dev-config/**' + - 'wsl-comfort/**' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + guard: + name: Signed copy guard + runs-on: windows-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Collect PR-changed signed-copy paths + id: changed + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $base = "${{ github.base_ref }}" + git fetch origin $base --depth=0 | Out-Null + $diffRange = "origin/$base...HEAD" + # --diff-filter=ACMRD covers Added, Copied, Modified, Renamed, Deleted. + # For renames git produces both the old and new path as separate lines + # (because --name-only flattens R entries into two paths). + $changed = git diff --name-only --diff-filter=ACMRD $diffRange -- ` + 'Workloads/**' 'windows-dev-config/**' 'wsl-comfort/**' + if ($null -eq $changed) { $changed = @() } + $changed = @($changed | Where-Object { $_ -and $_.Trim().Length -gt 0 }) + # Normalize to forward slashes so the set matches the comparator's + # `path` field exactly. + $changed = $changed | ForEach-Object { ($_ -replace '\\', '/').Trim() } | Sort-Object -Unique + Write-Host "Changed signed-copy paths ($($changed.Count)):" + $changed | ForEach-Object { Write-Host " - $_" } + $changed | Set-Content -Path changed-paths.txt -Encoding utf8 + + - name: Run signed-copy drift comparator + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + pwsh -NoProfile -File ./src/tools/check-signed-drift.ps1 ` + -RepoRoot . ` + -OutPath drift-report.json | Out-Null + if (-not (Test-Path drift-report.json)) { + throw "drift-report.json was not produced" + } + Write-Host "drift-report.json written ($((Get-Item drift-report.json).Length) bytes)" + + - name: Evaluate drift against PR-changed paths + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $changed = @() + if (Test-Path changed-paths.txt) { + $changed = @(Get-Content -LiteralPath changed-paths.txt | + Where-Object { $_ -and $_.Trim().Length -gt 0 } | + ForEach-Object { $_.Trim() }) + } + + $report = Get-Content -LiteralPath drift-report.json -Raw | ConvertFrom-Json + + # Build the set of PR-changed paths we care about (already filtered + # to the three signed-copy roots by the diff pathspec). + $changedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($p in $changed) { [void]$changedSet.Add($p) } + + # An entry is in scope if either its `path` (signed-copy side) or + # its `src_path` (source side) was touched by this PR. We accept + # both so renames / additions under src/ that are still missing on + # the root side are caught when the PR also touches the root tree. + $offenders = @() + foreach ($entry in $report.files) { + if ($entry.status -eq 'ok') { continue } + if ($changedSet.Contains($entry.path) -or $changedSet.Contains($entry.src_path)) { + $offenders += $entry + } + } + + $summary = $env:GITHUB_STEP_SUMMARY + + if ($offenders.Count -eq 0) { + $msg = "✅ Signed copy guard: no drift in the $($changed.Count) signed-copy path(s) touched by this PR." + Write-Host $msg + if ($summary) { + "## Signed copy guard" | Out-File -FilePath $summary -Append -Encoding utf8 + "" | Out-File -FilePath $summary -Append -Encoding utf8 + $msg | Out-File -FilePath $summary -Append -Encoding utf8 + } + exit 0 + } + + $header = "❌ The following files were edited in this PR but do not match ``src/`` (modulo the Authenticode signature block on ``.ps1`` files):" + $lines = @($header, "") + foreach ($o in $offenders) { + switch ($o.status) { + 'drifted' { + $lines += "- ``$($o.path)`` (drifted) — edit ``$($o.src_path)`` instead" + } + 'missing-in-src' { + $lines += "- ``$($o.path)`` (missing-in-src) — no matching ``$($o.src_path)`` exists; add it to ``src/`` (the sign pipeline will mirror it to the top level on the next cycle)" + } + 'missing-in-root' { + $lines += "- ``$($o.path)`` (missing-in-root) — no matching signed copy exists; the sign pipeline will produce it on the next cycle, so this PR should not touch the top-level path" + } + default { + $lines += "- ``$($o.path)`` ($($o.status)) — $($o.reason)" + } + } + } + $lines += "" + $lines += "The top-level signed copies are regenerated by the sign pipeline from ``src/``. See ``src/docs/development.md#signed-copy-drift-guard`` for the full explanation." + + $body = [string]::Join("`n", $lines) + [Console]::Error.WriteLine($body) + if ($summary) { + "## Signed copy guard" | Out-File -FilePath $summary -Append -Encoding utf8 + "" | Out-File -FilePath $summary -Append -Encoding utf8 + $body | Out-File -FilePath $summary -Append -Encoding utf8 + } + exit 1 + + - name: Upload drift report + if: always() + uses: actions/upload-artifact@v4 + with: + name: signed-copy-drift-report + path: drift-report.json + if-no-files-found: ignore diff --git a/src/docs/development.md b/src/docs/development.md index 9364fca..5ae1195 100644 --- a/src/docs/development.md +++ b/src/docs/development.md @@ -132,6 +132,14 @@ This repo carries **two parallel copies** of every flow: - Don't expect the two trees to be byte-identical. The signed copies carry an Authenticode signature block (`# SIG # Begin signature block` … `# SIG # End signature block`); the bodies above that marker should match what's in `src/`. They will diverge for the window between a `src/` change landing on `main` and the next sign cycle catching up. - Don't add a third copy of anything. Both copies exist for one reason only (to ship signed PS1s without losing the unsigned source), and any new flow or shared script lives only in `src/` until the sign pipeline mirrors it. +### Signed-copy drift guard + +A PR check named **`Signed copy guard`** ([`.github/workflows/signed-copy-guard.yml`](../../.github/workflows/signed-copy-guard.yml)) runs **only when a PR touches files under `Workloads/`, `windows-dev-config/`, or `wsl-comfort/`** (i.e. one of the three top-level signed-copy roots). For every PR-touched file in those roots, it fails the job if the file no longer matches its `src/` counterpart — modulo the Authenticode signature block on `.ps1` files (the body above `# SIG # Begin signature block` must match; everything else, including `.winget` / `.sh` / `.md` / images / any new extension, must be strictly byte-equal). PRs that edit only `src/` skip this check entirely; the sign pipeline will mirror those changes to the top level on the next sign cycle. + +The drift definition is implemented by the shared comparator [`src/tools/check-signed-drift.ps1`](../tools/check-signed-drift.ps1), which is the single source of truth for "what counts as drift". It is a pure reporter — it always exits 0 and emits a JSON report; the workflow decides pass/fail. A follow-up PR will add a non-blocking "Drift status" visibility check that reuses the same script. + +Maintainers: once this guard has landed, add **`Signed copy guard`** to the required status checks in `main`'s branch protection so PRs cannot bypass it. The guard does **not** replace the sign pipeline; it only prevents human edits to the top-level copies between sign cycles. + ## Prerequisites (Windows) Every flow — and the [Command Palette extension](../future/cmdpal/) — installs diff --git a/src/tools/check-signed-drift.ps1 b/src/tools/check-signed-drift.ps1 new file mode 100644 index 0000000..fb20efa --- /dev/null +++ b/src/tools/check-signed-drift.ps1 @@ -0,0 +1,252 @@ +<# +.SYNOPSIS + Compares the top-level signed copies (Workloads/, windows-dev-config/, + wsl-comfort/) against their src/ sources and emits a JSON drift report. + +.DESCRIPTION + The repository carries two parallel copies of every flow: the editable + source under src/{Workloads,windows-dev-config,wsl-comfort}/ and the + Authenticode-signed release copy at the matching top-level paths. + The sign pipeline (.pipelines/OneBranch.SignAndPackage.yml) regenerates + the top-level copies by signing src/**/*.ps1 (appending a + "# SIG # Begin signature block" footer) and mirroring every other file + (.winget / .sh / .md / images / anything else) byte-for-byte. + + This script walks every file under both trees, pairs each one with its + counterpart, and classifies it as ok | drifted | missing-in-root | + missing-in-src. For .ps1 files the comparison strips the UTF-8 BOM, + normalizes CRLF to LF, and on the root copy drops everything from the + first "# SIG # Begin signature block" line to EOF. For every other + file the comparison is a strict byte-equal. + + The script always exits 0; it is a pure reporter. Callers decide + pass/fail based on the JSON output. + +.PARAMETER RepoRoot + Repository root. Defaults to the parent of src/tools/ (i.e. two levels + up from this script). Pass an explicit path to run against an alternate + checkout. + +.PARAMETER OutPath + Optional file path to write the JSON report to (in addition to stdout). + +.OUTPUTS + JSON drift report on stdout. Always exits 0. +#> +[CmdletBinding()] +param( + [string]$RepoRoot, + [string]$OutPath +) + +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + # src/tools/check-signed-drift.ps1 -> repo root is two directories up. + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +} else { + $RepoRoot = (Resolve-Path $RepoRoot).Path +} + +$signedRoots = @('Workloads', 'windows-dev-config', 'wsl-comfort') +$utf8Bom = [byte[]](0xEF, 0xBB, 0xBF) + +function ConvertTo-PosixPath { + param([string]$Path) + return ($Path -replace '\\', '/') +} + +function Get-RelativePath { + param( + [string]$Base, + [string]$Full + ) + # Build a normalized "Base\" prefix and strip it. Avoids platform churn + # with [System.IO.Path]::GetRelativePath and PowerShell 7 quirks. + $baseFull = (Resolve-Path $Base).Path + if (-not $baseFull.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { + $baseFull = $baseFull + [System.IO.Path]::DirectorySeparatorChar + } + if ($Full.StartsWith($baseFull, [System.StringComparison]::OrdinalIgnoreCase)) { + return $Full.Substring($baseFull.Length) + } + return $Full +} + +function Get-NormalizedPs1Bytes { + param( + [byte[]]$Bytes, + [switch]$StripSignatureBlock + ) + + if ($null -eq $Bytes) { return [byte[]]@() } + + # Strip leading UTF-8 BOM if present. + if ($Bytes.Length -ge 3 -and + $Bytes[0] -eq $utf8Bom[0] -and + $Bytes[1] -eq $utf8Bom[1] -and + $Bytes[2] -eq $utf8Bom[2]) { + $Bytes = $Bytes[3..($Bytes.Length - 1)] + } + + $text = [System.Text.Encoding]::UTF8.GetString($Bytes) + + # Split on both CRLF and lone LF so we can compare against either side. + $lines = $text -split "`r`n|`n" + + if ($StripSignatureBlock) { + $cutoff = -1 + for ($i = 0; $i -lt $lines.Length; $i++) { + if ($lines[$i].Trim() -eq '# SIG # Begin signature block') { + $cutoff = $i + break + } + } + if ($cutoff -ge 0) { + if ($cutoff -eq 0) { + $lines = @() + } else { + $lines = $lines[0..($cutoff - 1)] + } + } + } + + $joined = [string]::Join("`n", $lines) + return [System.Text.Encoding]::UTF8.GetBytes($joined) +} + +function Get-FirstDifferenceOffset { + param( + [byte[]]$A, + [byte[]]$B + ) + $min = [Math]::Min($A.Length, $B.Length) + for ($i = 0; $i -lt $min; $i++) { + if ($A[$i] -ne $B[$i]) { return $i } + } + if ($A.Length -ne $B.Length) { return $min } + return -1 +} + +function Compare-Pair { + param( + [string]$RootRelPath, # e.g. Workloads/python/install.ps1 + [string]$SrcAbsPath, + [string]$RootAbsPath + ) + + $srcExists = Test-Path -LiteralPath $SrcAbsPath -PathType Leaf + $rootExists = Test-Path -LiteralPath $RootAbsPath -PathType Leaf + + $entry = [ordered]@{ + path = ConvertTo-PosixPath $RootRelPath + src_path = ConvertTo-PosixPath ("src/" + $RootRelPath) + status = $null + reason = $null + } + + if (-not $srcExists -and -not $rootExists) { + # Should never happen — we only enumerate paths that exist somewhere. + $entry.status = 'ok' + return [pscustomobject]$entry + } + + if (-not $srcExists) { + $entry.status = 'missing-in-src' + $entry.reason = "no matching source at src/$($entry.path)" + return [pscustomobject]$entry + } + + if (-not $rootExists) { + $entry.status = 'missing-in-root' + $entry.reason = "no matching signed copy at $($entry.path)" + return [pscustomobject]$entry + } + + $srcBytes = [System.IO.File]::ReadAllBytes($SrcAbsPath) + $rootBytes = [System.IO.File]::ReadAllBytes($RootAbsPath) + + $isPs1 = [System.IO.Path]::GetExtension($RootRelPath).Equals('.ps1', [System.StringComparison]::OrdinalIgnoreCase) + + if ($isPs1) { + $srcNorm = Get-NormalizedPs1Bytes -Bytes $srcBytes + $rootNorm = Get-NormalizedPs1Bytes -Bytes $rootBytes -StripSignatureBlock + $offset = Get-FirstDifferenceOffset -A $srcNorm -B $rootNorm + if ($offset -lt 0) { + $entry.status = 'ok' + } else { + $entry.status = 'drifted' + $entry.reason = "normalized .ps1 bytes differ at offset $offset (src len=$($srcNorm.Length), root len=$($rootNorm.Length))" + } + } else { + $offset = Get-FirstDifferenceOffset -A $srcBytes -B $rootBytes + if ($offset -lt 0) { + $entry.status = 'ok' + } else { + $entry.status = 'drifted' + $entry.reason = "bytes differ at offset $offset (src len=$($srcBytes.Length), root len=$($rootBytes.Length))" + } + } + + return [pscustomobject]$entry +} + +# --------------------------------------------------------------------------- +# Enumerate every file under the three roots in both trees and build the +# union of root-relative paths to compare. +# --------------------------------------------------------------------------- +$pairs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +foreach ($root in $signedRoots) { + $srcDir = Join-Path $RepoRoot (Join-Path 'src' $root) + $rootDir = Join-Path $RepoRoot $root + + if (Test-Path -LiteralPath $srcDir -PathType Container) { + Get-ChildItem -LiteralPath $srcDir -Recurse -File -Force | ForEach-Object { + $rel = Get-RelativePath -Base $srcDir -Full $_.FullName + [void]$pairs.Add((Join-Path $root $rel)) + } + } + + if (Test-Path -LiteralPath $rootDir -PathType Container) { + Get-ChildItem -LiteralPath $rootDir -Recurse -File -Force | ForEach-Object { + $rel = Get-RelativePath -Base $rootDir -Full $_.FullName + [void]$pairs.Add((Join-Path $root $rel)) + } + } +} + +$results = [System.Collections.Generic.List[object]]::new() +foreach ($rel in $pairs) { + $srcAbs = Join-Path $RepoRoot (Join-Path 'src' $rel) + $rootAbs = Join-Path $RepoRoot $rel + $results.Add((Compare-Pair -RootRelPath $rel -SrcAbsPath $srcAbs -RootAbsPath $rootAbs)) +} + +# Deterministic ordering by root-relative posix path. +$sorted = $results | Sort-Object -Property path + +$summary = [ordered]@{ + ok = ($sorted | Where-Object { $_.status -eq 'ok' }).Count + drifted = ($sorted | Where-Object { $_.status -eq 'drifted' }).Count + missing_in_root = ($sorted | Where-Object { $_.status -eq 'missing-in-root' }).Count + missing_in_src = ($sorted | Where-Object { $_.status -eq 'missing-in-src' }).Count +} + +$report = [ordered]@{ + summary = $summary + files = @($sorted) +} + +$json = $report | ConvertTo-Json -Depth 8 + +if (-not [string]::IsNullOrWhiteSpace($OutPath)) { + $outDir = [System.IO.Path]::GetDirectoryName($OutPath) + if (-not [string]::IsNullOrWhiteSpace($outDir) -and -not (Test-Path -LiteralPath $outDir)) { + New-Item -ItemType Directory -Force -Path $outDir | Out-Null + } + [System.IO.File]::WriteAllText($OutPath, $json, [System.Text.UTF8Encoding]::new($false)) +} + +Write-Output $json +exit 0