Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions .github/workflows/signed-copy-guard.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions src/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading