From dcd5c0e778d9c6b15c203f7a20fd5de0d6e289f5 Mon Sep 17 00:00:00 2001 From: Adam Rudell Date: Thu, 13 Aug 2026 10:35:28 -0500 Subject: [PATCH 1/4] Add wiki publish workflow and wire up generate-documentation.ps1 - generate-documentation.ps1 now generates a _Sidebar.md and accepts an optional -WikiPath to sync generated function docs into a wiki checkout, removing stale function pages (identified via approved PowerShell verbs) while leaving hand-authored pages like Home.md untouched. - New publish-documentation.yml workflow runs on push to main (src/** changes) and workflow_dispatch, checks out the wiki repo, regenerates docs, and pushes changes directly to the wiki. No documentation is stored in the source repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae912379-0ace-40f9-bfaf-75540b4e6fa3 --- .build/generate-documentation.ps1 | 57 +++++++++++++++++- .github/workflows/publish-documentation.yml | 67 +++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/publish-documentation.yml diff --git a/.build/generate-documentation.ps1 b/.build/generate-documentation.ps1 index 0a38c2e1..68ab2521 100644 --- a/.build/generate-documentation.ps1 +++ b/.build/generate-documentation.ps1 @@ -2,9 +2,22 @@ .SYNOPSIS Builds the markdown documentation for the module. .DESCRIPTION - Builds the markdown documentation for the module using the PlatyPS PowerShell module. + Builds the markdown documentation for the module using the PlatyPS PowerShell module. Generates one + markdown page per exported function plus a _Sidebar.md for navigation. When -WikiPath is specified, + the generated documentation is also synchronized into the provided wiki checkout: exported function + pages are copied/overwritten, and function pages for functions that no longer exist are removed. Any + other hand-authored wiki pages (e.g. Home.md) are left untouched. + .PARAMETER WikiPath + Path to a local checkout of the project's GitHub wiki repository (e.g. a checkout of + microsoft/SdnDiagnostics.wiki). When specified, generated documentation is synchronized into this path. #> +[CmdletBinding()] +param ( + [Parameter(Mandatory = $false)] + [System.String]$WikiPath +) + $ErrorActionPreference = "Stop" $platyFromPoshGallery = Find-Module -Name platyPS @@ -21,7 +34,7 @@ else { $modulePath = "$PSScriptRoot\..\src\SdnDiagnostics.psd1" $docPath = "$PSScriptRoot\..\.documentation\functions" -$sideBarNav = "$PSScriptRoot\..\.documentation\_SideBar.md" +$sideBarPath = "$PSScriptRoot\..\.documentation\_Sidebar.md" if(-NOT (Test-Path -Path $docPath -PathType Container)) { $null = New-Item -Path $docPath -ItemType Directory -Force @@ -41,9 +54,47 @@ if($oldArticles){ "Generating function documentation" | Write-Host $null = New-MarkdownHelp -Module SdnDiagnostics -OutputFolder $docPath -NoMetadata -Force +$exportedFunctions = Get-Command -Module SdnDiagnostics | Sort-Object -Property Name $currentFiles = Get-ChildItem -Path $docPath\* -Include *.md -foreach($function in (Get-Command -Module SdnDiagnostics)){ +foreach($function in $exportedFunctions){ if($function.Name -inotin ($currentFiles).BaseName){ "Documentation not generated for {0}" -f $function.Name | Write-Host -ForegroundColor:Yellow } } + +# generate a sidebar so the wiki has consistent navigation to each function page +"Generating wiki sidebar" | Write-Host +$sideBarContent = [System.Collections.Generic.List[string]]::new() +$sideBarContent.Add('# SdnDiagnostics') +$sideBarContent.Add('') +$sideBarContent.Add('[Home](Home)') +$sideBarContent.Add('') +$sideBarContent.Add('## Functions') +foreach($function in $exportedFunctions){ + $sideBarContent.Add("- [$($function.Name)]($($function.Name))") +} +$sideBarContent | Set-Content -Path $sideBarPath -Force + +if($WikiPath){ + if(-NOT (Test-Path -Path $WikiPath -PathType Container)){ + throw "WikiPath '$WikiPath' does not exist or is not a directory." + } + + "Synchronizing generated documentation into wiki path '{0}'" -f $WikiPath | Write-Host + + # approved verbs are used to identify previously-generated function pages so that hand-authored + # wiki pages (e.g. Home.md) are never touched or removed by this sync + $approvedVerbs = (Get-Verb).Verb + $verbPattern = "^($($approvedVerbs -join '|'))-" + + $exportedFunctionNames = $exportedFunctions.Name + $existingWikiFunctionDocs = Get-ChildItem -Path "$WikiPath\*" -Include *.md | Where-Object { $_.BaseName -match $verbPattern } + $staleWikiFunctionDocs = $existingWikiFunctionDocs | Where-Object { $_.BaseName -inotin $exportedFunctionNames } + if($staleWikiFunctionDocs){ + "Removing {0} stale function page(s) from wiki" -f $staleWikiFunctionDocs.Count | Write-Host + $staleWikiFunctionDocs | Remove-Item -Force + } + + Get-ChildItem -Path "$docPath\*" -Include *.md | Copy-Item -Destination $WikiPath -Force + Copy-Item -Path $sideBarPath -Destination $WikiPath -Force +} diff --git a/.github/workflows/publish-documentation.yml b/.github/workflows/publish-documentation.yml new file mode 100644 index 00000000..5147ba79 --- /dev/null +++ b/.github/workflows/publish-documentation.yml @@ -0,0 +1,67 @@ +name: Publish Documentation + +# Controls when the workflow will run +on: + # Triggers the workflow on push events but only for the main branch, and only when + # exported function source may have changed. + push: + branches: + - main + paths: + - 'src/**' + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish-documentation: + # The type of runner that the job will run on + runs-on: windows-latest + + permissions: + # required to push the generated documentation to the wiki + contents: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + + - name: 'Checkout SdnDiagnostics' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: main + + - name: 'Checkout SdnDiagnostics Wiki' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.repository }}.wiki + path: wiki + + - name: 'Generate and Sync Function Documentation' + run: | + $wikiPath = (Resolve-Path -Path .\wiki).Path + & .\main\.build\generate-documentation.ps1 -WikiPath $wikiPath + shell: powershell + + - name: 'Publish to Wiki' + run: | + Set-Location -Path .\wiki + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + + $changes = git status --porcelain + if ($changes) { + $shortSha = "${env:GITHUB_SHA}".Substring(0, 7) + git commit -m "docs: sync function documentation from main@$shortSha" + git push + } + else { + "No documentation changes to publish" | Write-Host + } + shell: powershell From 120426d6a0ae96ab5732827471a19c32946b51cc Mon Sep 17 00:00:00 2001 From: Adam Rudell Date: Thu, 13 Aug 2026 11:28:16 -0500 Subject: [PATCH 2/4] Fix wiki sync layout: functions subfolder and _SideBar.md merge - Copy generated function docs into a functions\ subfolder of the wiki checkout (matching the real SdnDiagnostics.wiki structure) instead of the wiki root. - Target the sidebar file at the wiki's actual _SideBar.md (exact casing), not a locally-generated _Sidebar.md. - Only regenerate the ## Functions section of _SideBar.md; preserve all hand-authored content above it (Documentation, How To Guides, Troubleshooting Guides, Learning sections) verbatim. - Update stale function page detection/removal to scan functions\ instead of the wiki root. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae912379-0ace-40f9-bfaf-75540b4e6fa3 --- .build/generate-documentation.ps1 | 84 ++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/.build/generate-documentation.ps1 b/.build/generate-documentation.ps1 index 68ab2521..0fc40efb 100644 --- a/.build/generate-documentation.ps1 +++ b/.build/generate-documentation.ps1 @@ -2,11 +2,15 @@ .SYNOPSIS Builds the markdown documentation for the module. .DESCRIPTION - Builds the markdown documentation for the module using the PlatyPS PowerShell module. Generates one - markdown page per exported function plus a _Sidebar.md for navigation. When -WikiPath is specified, - the generated documentation is also synchronized into the provided wiki checkout: exported function - pages are copied/overwritten, and function pages for functions that no longer exist are removed. Any - other hand-authored wiki pages (e.g. Home.md) are left untouched. + Builds the markdown documentation for the module using the PlatyPS PowerShell module, generating one + markdown page per exported function. When -WikiPath is specified, the generated documentation is also + synchronized into the provided wiki checkout, matching the SdnDiagnostics.wiki repository structure: + - Function pages are copied/overwritten into a `functions\` subfolder of the wiki. + - Function pages for functions that no longer exist are removed from `functions\`. + - The `## Functions` section of `_SideBar.md` is regenerated with a link to every exported function. + Any hand-authored content above the `## Functions` heading (e.g. Home, How To Guides, + Troubleshooting, Learning sections) is preserved as-is. + Other hand-authored wiki pages (e.g. Home.md) are never touched. .PARAMETER WikiPath Path to a local checkout of the project's GitHub wiki repository (e.g. a checkout of microsoft/SdnDiagnostics.wiki). When specified, generated documentation is synchronized into this path. @@ -34,7 +38,6 @@ else { $modulePath = "$PSScriptRoot\..\src\SdnDiagnostics.psd1" $docPath = "$PSScriptRoot\..\.documentation\functions" -$sideBarPath = "$PSScriptRoot\..\.documentation\_Sidebar.md" if(-NOT (Test-Path -Path $docPath -PathType Container)) { $null = New-Item -Path $docPath -ItemType Directory -Force @@ -62,19 +65,6 @@ foreach($function in $exportedFunctions){ } } -# generate a sidebar so the wiki has consistent navigation to each function page -"Generating wiki sidebar" | Write-Host -$sideBarContent = [System.Collections.Generic.List[string]]::new() -$sideBarContent.Add('# SdnDiagnostics') -$sideBarContent.Add('') -$sideBarContent.Add('[Home](Home)') -$sideBarContent.Add('') -$sideBarContent.Add('## Functions') -foreach($function in $exportedFunctions){ - $sideBarContent.Add("- [$($function.Name)]($($function.Name))") -} -$sideBarContent | Set-Content -Path $sideBarPath -Force - if($WikiPath){ if(-NOT (Test-Path -Path $WikiPath -PathType Container)){ throw "WikiPath '$WikiPath' does not exist or is not a directory." @@ -82,19 +72,67 @@ if($WikiPath){ "Synchronizing generated documentation into wiki path '{0}'" -f $WikiPath | Write-Host + # mirrors the SdnDiagnostics.wiki repository structure, where function pages live under a + # `functions\` subfolder alongside other hand-authored top-level wiki pages + $wikiFunctionsPath = Join-Path -Path $WikiPath -ChildPath "functions" + if(-NOT (Test-Path -Path $wikiFunctionsPath -PathType Container)) { + $null = New-Item -Path $wikiFunctionsPath -ItemType Directory -Force + } + # approved verbs are used to identify previously-generated function pages so that hand-authored - # wiki pages (e.g. Home.md) are never touched or removed by this sync + # wiki pages are never touched or removed by this sync $approvedVerbs = (Get-Verb).Verb $verbPattern = "^($($approvedVerbs -join '|'))-" $exportedFunctionNames = $exportedFunctions.Name - $existingWikiFunctionDocs = Get-ChildItem -Path "$WikiPath\*" -Include *.md | Where-Object { $_.BaseName -match $verbPattern } + $existingWikiFunctionDocs = Get-ChildItem -Path "$wikiFunctionsPath\*" -Include *.md | Where-Object { $_.BaseName -match $verbPattern } $staleWikiFunctionDocs = $existingWikiFunctionDocs | Where-Object { $_.BaseName -inotin $exportedFunctionNames } if($staleWikiFunctionDocs){ "Removing {0} stale function page(s) from wiki" -f $staleWikiFunctionDocs.Count | Write-Host $staleWikiFunctionDocs | Remove-Item -Force } - Get-ChildItem -Path "$docPath\*" -Include *.md | Copy-Item -Destination $WikiPath -Force - Copy-Item -Path $sideBarPath -Destination $WikiPath -Force + Get-ChildItem -Path "$docPath\*" -Include *.md | Copy-Item -Destination $wikiFunctionsPath -Force + + # regenerate only the "## Functions" section of _SideBar.md, preserving any hand-authored + # content (Home, How To Guides, Troubleshooting Guides, Learning, etc.) above that heading + "Updating wiki sidebar" | Write-Host + $sideBarWikiPath = Join-Path -Path $WikiPath -ChildPath "_SideBar.md" + $functionsHeadingPattern = '^#+\s*Functions\s*$' + + $prefixLines = [System.Collections.Generic.List[string]]::new() + if(Test-Path -Path $sideBarWikiPath -PathType Leaf) { + $existingSideBarLines = @(Get-Content -Path $sideBarWikiPath) + $headingIndex = -1 + for($i = 0; $i -lt $existingSideBarLines.Count; $i++){ + if($existingSideBarLines[$i] -match $functionsHeadingPattern){ + $headingIndex = $i + break + } + } + + if($headingIndex -ge 0){ + if($headingIndex -gt 0){ + $prefixLines.AddRange([string[]]$existingSideBarLines[0..($headingIndex - 1)]) + } + } + else { + $prefixLines.AddRange([string[]]$existingSideBarLines) + } + } + else { + "No existing _SideBar.md found at wiki root; creating a new one" | Write-Host -ForegroundColor:Yellow + } + + $newSideBarContent = [System.Collections.Generic.List[string]]::new() + $newSideBarContent.AddRange($prefixLines) + if($newSideBarContent.Count -gt 0 -and $newSideBarContent[$newSideBarContent.Count - 1] -ne ''){ + $newSideBarContent.Add('') + } + $newSideBarContent.Add('## Functions') + foreach($function in $exportedFunctions){ + $newSideBarContent.Add("- [$($function.Name)]($($function.Name))") + } + + $newSideBarContent | Set-Content -Path $sideBarWikiPath -Force } From 8cd0fca0e6d2e43ea8740b01ec23a5ef31dc6dca Mon Sep 17 00:00:00 2001 From: Adam Rudell Date: Thu, 13 Aug 2026 11:37:21 -0500 Subject: [PATCH 3/4] Address PR review feedback: fix wiki race condition and stale-page detection - Add concurrency group to publish-documentation workflow to serialize runs and prevent races on the wiki checkout/push; add retry-with-rebase logic around the wiki push for extra robustness against out-of-band edits. - Replace approved-verb-prefix regex matching for stale page detection with a manifest-based approach (.generated-manifest.json in wiki/functions/). Only pages previously generated by this script that are no longer exported are considered stale, guaranteeing hand-authored pages (even ones matching PowerShell verb-noun naming) are never removed. - Fix a double-nesting bug where wrapping ConvertFrom-Json output in @() collapsed the whole manifest array into a single nested element, corrupting stale-page comparisons. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae912379-0ace-40f9-bfaf-75540b4e6fa3 --- .build/generate-documentation.ps1 | 41 ++++++++++++++++----- .github/workflows/publish-documentation.yml | 27 +++++++++++++- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.build/generate-documentation.ps1 b/.build/generate-documentation.ps1 index 0fc40efb..6de844b5 100644 --- a/.build/generate-documentation.ps1 +++ b/.build/generate-documentation.ps1 @@ -79,21 +79,42 @@ if($WikiPath){ $null = New-Item -Path $wikiFunctionsPath -ItemType Directory -Force } - # approved verbs are used to identify previously-generated function pages so that hand-authored - # wiki pages are never touched or removed by this sync - $approvedVerbs = (Get-Verb).Verb - $verbPattern = "^($($approvedVerbs -join '|'))-" - + # a manifest of function names generated by this script on the previous run is used to identify + # which pages are safe to remove. Only pages that this script itself generated previously (and are + # no longer exported) are treated as stale -- a hand-authored page that happens to match a + # PowerShell approved-verb naming convention (e.g. functions\Get-Started.md) is never a candidate + # for removal because it will never appear in the manifest. $exportedFunctionNames = $exportedFunctions.Name - $existingWikiFunctionDocs = Get-ChildItem -Path "$wikiFunctionsPath\*" -Include *.md | Where-Object { $_.BaseName -match $verbPattern } - $staleWikiFunctionDocs = $existingWikiFunctionDocs | Where-Object { $_.BaseName -inotin $exportedFunctionNames } - if($staleWikiFunctionDocs){ - "Removing {0} stale function page(s) from wiki" -f $staleWikiFunctionDocs.Count | Write-Host - $staleWikiFunctionDocs | Remove-Item -Force + $manifestPath = Join-Path -Path $wikiFunctionsPath -ChildPath ".generated-manifest.json" + + if(Test-Path -Path $manifestPath -PathType Leaf) { + # ConvertFrom-Json writes its array result as a single non-enumerated pipeline object, so + # wrapping the whole pipeline in @(...) would double-nest it into a 1-element array containing + # the entire array. Assign directly instead, then coerce a single-name manifest (which + # ConvertFrom-Json unwraps to a plain string) into a 1-element array. + $previouslyGeneratedNames = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json + if($previouslyGeneratedNames -isnot [array]) { + $previouslyGeneratedNames = @($previouslyGeneratedNames) + } + $staleFunctionNames = $previouslyGeneratedNames | Where-Object { $_ -inotin $exportedFunctionNames } + if($staleFunctionNames){ + $staleWikiFunctionDocs = Get-ChildItem -Path "$wikiFunctionsPath\*" -Include *.md | Where-Object { $_.BaseName -iin $staleFunctionNames } + if($staleWikiFunctionDocs){ + "Removing {0} stale function page(s) from wiki" -f $staleWikiFunctionDocs.Count | Write-Host + $staleWikiFunctionDocs | Remove-Item -Force + } + } + } + else { + "No generated-page manifest found; skipping stale page removal for this run" | Write-Host -ForegroundColor:Yellow } Get-ChildItem -Path "$docPath\*" -Include *.md | Copy-Item -Destination $wikiFunctionsPath -Force + # record which function pages this script generated so a future run can safely identify stale + # pages without guessing based on naming convention alone + $exportedFunctionNames | ConvertTo-Json | Set-Content -Path $manifestPath -Force + # regenerate only the "## Functions" section of _SideBar.md, preserving any hand-authored # content (Home, How To Guides, Troubleshooting Guides, Learning, etc.) above that heading "Updating wiki sidebar" | Write-Host diff --git a/.github/workflows/publish-documentation.yml b/.github/workflows/publish-documentation.yml index 5147ba79..9783a1f8 100644 --- a/.github/workflows/publish-documentation.yml +++ b/.github/workflows/publish-documentation.yml @@ -13,6 +13,13 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +# Serialize runs so concurrent pushes to main can't race on the wiki checkout/push. Runs are queued +# (not cancelled) so every triggering commit still gets published, each one regenerating docs from +# its own checkout after the previous run's push has completed. +concurrency: + group: publish-documentation-wiki + cancel-in-progress: false + permissions: contents: read @@ -59,7 +66,25 @@ jobs: if ($changes) { $shortSha = "${env:GITHUB_SHA}".Substring(0, 7) git commit -m "docs: sync function documentation from main@$shortSha" - git push + + # the concurrency group above serializes runs of this workflow, but the wiki can also be + # edited out-of-band (e.g. manually). Retry a couple of times with a rebase in that case + # rather than failing the run outright. + $pushed = $false + for ($attempt = 1; $attempt -le 3 -and -not $pushed; $attempt++) { + git push + if ($LASTEXITCODE -eq 0) { + $pushed = $true + } + elseif ($attempt -lt 3) { + "Push failed (attempt $attempt), pulling and retrying" | Write-Host -ForegroundColor Yellow + git pull --rebase + } + } + + if (-not $pushed) { + throw "Failed to push documentation changes to the wiki after multiple attempts" + } } else { "No documentation changes to publish" | Write-Host From 827b140e94b9568a31400ece1a387a3ed647e0bc Mon Sep 17 00:00:00 2001 From: Adam Rudell Date: Thu, 13 Aug 2026 11:42:18 -0500 Subject: [PATCH 4/4] Log the list of articles published to the wiki in generate-documentation.ps1 Prints the full set of function articles synced to the wiki functions\ folder each run, flagging newly-added pages and naming each removed stale page, so the published article list is visible directly in the pipeline log without needing to inspect the wiki repo's commit diff. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae912379-0ace-40f9-bfaf-75540b4e6fa3 --- .build/generate-documentation.ps1 | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.build/generate-documentation.ps1 b/.build/generate-documentation.ps1 index 6de844b5..4a096505 100644 --- a/.build/generate-documentation.ps1 +++ b/.build/generate-documentation.ps1 @@ -100,17 +100,35 @@ if($WikiPath){ if($staleFunctionNames){ $staleWikiFunctionDocs = Get-ChildItem -Path "$wikiFunctionsPath\*" -Include *.md | Where-Object { $_.BaseName -iin $staleFunctionNames } if($staleWikiFunctionDocs){ - "Removing {0} stale function page(s) from wiki" -f $staleWikiFunctionDocs.Count | Write-Host + "Removing {0} stale function page(s) from wiki:" -f $staleWikiFunctionDocs.Count | Write-Host + $staleWikiFunctionDocs | ForEach-Object { " - {0}" -f $_.Name | Write-Host } $staleWikiFunctionDocs | Remove-Item -Force } } + + # functions present in the exported set but not in the previous manifest are newly-added pages + $newFunctionNames = $exportedFunctionNames | Where-Object { $_ -inotin $previouslyGeneratedNames } } else { "No generated-page manifest found; skipping stale page removal for this run" | Write-Host -ForegroundColor:Yellow + # first run: every generated page is "new" from the wiki's perspective + $newFunctionNames = $exportedFunctionNames } Get-ChildItem -Path "$docPath\*" -Include *.md | Copy-Item -Destination $wikiFunctionsPath -Force + # summarize exactly which articles were published to the wiki on this run so it is visible in + # the pipeline log without needing to inspect the wiki repository's commit diff afterwards + "Publishing {0} function article(s) to wiki path 'functions\':" -f $exportedFunctionNames.Count | Write-Host + foreach($name in $exportedFunctionNames){ + if($name -iin $newFunctionNames){ + " - {0} (new)" -f $name | Write-Host -ForegroundColor:Green + } + else { + " - {0}" -f $name | Write-Host + } + } + # record which function pages this script generated so a future run can safely identify stale # pages without guessing based on naming convention alone $exportedFunctionNames | ConvertTo-Json | Set-Content -Path $manifestPath -Force