Skip to content
Draft
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
80 changes: 80 additions & 0 deletions .github/workflows/drift-visibility.yml
Original file line number Diff line number Diff line change
@@ -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."
}
156 changes: 156 additions & 0 deletions .github/workflows/signed-copy-guard.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions src/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,22 @@ 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. 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
Expand Down
Loading
Loading