From 70a23b9a939d01c732f6dcc353503fa56baf903d Mon Sep 17 00:00:00 2001 From: Clint Rutkas Date: Wed, 27 May 2026 22:34:32 -0700 Subject: [PATCH 1/3] Add signed-copy drift guard (check 2 of 2) Introduces src/tools/check-signed-drift.ps1 (shared comparator) and .github/workflows/signed-copy-guard.yml. The workflow runs on PRs that touch the top-level signed-copy roots and fails if any PR-touched file no longer matches its src/ counterpart (modulo the Authenticode signature block on .ps1 files). A follow-up PR will layer a non-blocking "Drift status" visibility check on the same comparator (check 1 of 2). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/signed-copy-guard.yml | 156 +++++++++++++++ src/docs/development.md | 8 + src/tools/check-signed-drift.ps1 | 252 ++++++++++++++++++++++++ 3 files changed, 416 insertions(+) create mode 100644 .github/workflows/signed-copy-guard.yml create mode 100644 src/tools/check-signed-drift.ps1 diff --git a/.github/workflows/signed-copy-guard.yml b/.github/workflows/signed-copy-guard.yml new file mode 100644 index 0000000..d17b4dd --- /dev/null +++ b/.github/workflows/signed-copy-guard.yml @@ -0,0 +1,156 @@ +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] + 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 From d8046100be0f1cc15c1b5b640c518b299c755943 Mon Sep 17 00:00:00 2001 From: Clint Rutkas Date: Wed, 27 May 2026 22:43:49 -0700 Subject: [PATCH 2/3] Add drift status visibility check (check 1 of 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces .github/workflows/drift-visibility.yml. The workflow runs on every PR (no paths filter) and reuses the shared comparator script introduced by the preceding signed-copy-drift-guard branch. When any file drift exists between src/ and the top-level signed copies, the job exits non-zero so the check surfaces as a red ❌ entry named "Drift status" in the PR's "Some checks were not successful" panel, with a markdown table written to the job summary listing every drifted / missing file. Unlike the signed-copy guard, this check is informational — drift is expected in the window between a src/ change landing on main and the next sign-pipeline cycle catching up — and is not intended to be a required status check by default. The user-facing intent is purely visibility: "I'm OK with drift, the issue is not knowing there is drift." Stacked on top of signed-copy-drift-guard. The companion docs subsection in src/docs/development.md replaces that branch's forward-reference sentence with a back-reference to this new "Drift status" subsection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/drift-visibility.yml | 80 ++++++++++++++++++++++++++ src/docs/development.md | 10 +++- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/drift-visibility.yml diff --git a/.github/workflows/drift-visibility.yml b/.github/workflows/drift-visibility.yml new file mode 100644 index 0000000..460d140 --- /dev/null +++ b/.github/workflows/drift-visibility.yml @@ -0,0 +1,80 @@ +name: Drift status + +# Informational PR check: surfaces drift between `src/` and the top-level +# signed copies. See /src/docs/development.md#repo-layout-signed-vs-source for +# the source-of-truth model. +# +# This workflow does NOT block merge by default. It exists so that drift is +# visible at the top of every PR — when drift exists, the job exits non-zero +# and the check surfaces as a red ❌ in the PR's "Some checks were not +# successful" panel. The intent is purely visibility: drift is expected in +# the window between a `src/` change landing on `main` and the next sign +# pipeline cycle mirroring it to the top-level copies. +# +# Enforcement of "you must not edit the top-level signed copies directly" is +# handled by the separate `signed-copy-guard.yml` workflow. + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + drift-status: + name: Drift status + runs-on: windows-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run comparator + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + ./src/tools/check-signed-drift.ps1 -RepoRoot . -OutPath drift-report.json | Out-Null + + - name: Summarize and set status + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $report = Get-Content drift-report.json -Raw | ConvertFrom-Json + $s = $report.summary + $total = [int]$s.drifted + [int]$s.missing_in_root + [int]$s.missing_in_src + + $lines = New-Object 'System.Collections.Generic.List[string]' + if ($total -eq 0) { + $lines.Add('# ✅ Signed copies in sync with `src/`') + $lines.Add('') + $lines.Add("$($s.ok) file(s) checked. No drift detected.") + } else { + $lines.Add("# ⚠️ $total file(s) drifted between ``src/`` and the top-level signed copies") + $lines.Add('') + $lines.Add('> **This check is informational and does not block merge.** Drift is expected in the window between a `src/` change landing on `main` and the next sign pipeline cycle. See [Repo layout: signed vs source](https://github.com/microsoft/WindowsDeveloperConfig/blob/main/src/docs/development.md#repo-layout-signed-vs-source) and the [`signed-copy-guard`](https://github.com/microsoft/WindowsDeveloperConfig/blob/main/.github/workflows/signed-copy-guard.yml) workflow, which *does* block PRs that edit the top-level signed copies directly.') + $lines.Add('') + $lines.Add('| Path | Status | Detail |') + $lines.Add('| --- | --- | --- |') + foreach ($f in $report.files) { + if ($f.status -eq 'ok') { continue } + $detail = if ($f.reason) { $f.reason } else { '—' } + $lines.Add("| ``$($f.path)`` | $($f.status) | $detail |") + } + $lines.Add('') + $lines.Add("Totals: ok=$($s.ok), drifted=$($s.drifted), missing-in-root=$($s.missing_in_root), missing-in-src=$($s.missing_in_src).") + } + + ($lines -join "`n") | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + if ($total -gt 0) { + Write-Host "::notice::$total file(s) drifted from src/ — see job summary for the full list." + exit 1 + } else { + Write-Host "No drift detected." + } diff --git a/src/docs/development.md b/src/docs/development.md index 5ae1195..342b9ea 100644 --- a/src/docs/development.md +++ b/src/docs/development.md @@ -136,10 +136,18 @@ This repo carries **two parallel copies** of every flow: 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. +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. The companion [Drift status](#drift-status-visibility-check-non-blocking) check below 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. +### Drift status (visibility check, non-blocking) + +Sibling to the [Signed-copy drift guard](#signed-copy-drift-guard) above: the `Drift status` PR check ([`.github/workflows/drift-visibility.yml`](../../.github/workflows/drift-visibility.yml)) runs on **every PR** (no paths filter) and surfaces *any* drift between `src/` and the top-level signed copies — not just files the PR itself touched. It calls the same `src/tools/check-signed-drift.ps1` comparator as the guard, so "what counts as drift" stays defined in exactly one place. + +When drift exists, the job exits non-zero and the check surfaces as a red ❌ entry named **`Drift status`** inside the PR's "Some checks were not successful" panel at the top of the conversation, with the full drift table written to the job summary. **The check is informational** — it is not intended to be a required status check by default; drift is expected during the window between a `src/` change landing on `main` and the next sign-pipeline cycle catching up. + +In short: `Signed copy guard` is the enforcement signal ("did this PR edit the wrong copy?"); `Drift status` is the awareness signal ("does the repo currently have any drift?"). Two checks, one comparator. + ## Prerequisites (Windows) Every flow — and the [Command Palette extension](../future/cmdpal/) — installs From 416a7744cfbeb8734d965c6618dc3f937493c8b4 Mon Sep 17 00:00:00 2001 From: Clint Rutkas Date: Wed, 27 May 2026 22:48:55 -0700 Subject: [PATCH 3/3] Trigger CI after PR base retarget to main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>